From b6f3dd0e7450a84cc5087ee0cccb63b6f7442dc8 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 19 Oct 2025 15:45:26 +0900 Subject: [PATCH 01/38] update show_schedule_register.md to include 'use' object with sectionId, excludeSeatIds, and gradeAssignments in API request examples --- docs/specs/api/show_schedule_register.md | 27 ++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 792a2c8..399284d 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,13 @@ - [x] 존재하지 않는 showId를 보내면 NOT_FOUND 상태코드를 반환한다 - [x] 공연 기간 범위를 벗어나는 startAt 또는 endAt을 보낼 경우 BAD_REQUEST를 반환한다 - [x] 동일한 hallId와 시간이 겹치는 회차를 등록하려 하면 INTERNAL_SERVER_ERROR를 반환한다 +- [ ] showId에 해당하는 hall에 해당하는 sectionId를 찾을 수 없으면 NOT_FOUND를 반환한다 +- [ ] excludeSeatIds에 해당 section의 id가 아닌 좌석 id가 포함되면 NOT_FOUND를 반환한다 +- [ ] excludeSeatIds에 중복된 좌석이 있는 경우 BAD_REQUEST를 반환한다 +- [ ] gradeAssignments의 gradeId가 해당 show에 존재하지 않으면 NOT_FOUND를 반환한다 +- [ ] gradeAssignments의 seatIds에 해당 hall의 seat id가 존재하지 않는 경우 NOT_FOUND를 반환한다 +- [ ] gradeAssignments의 seatIds에 중복된 좌석이 존재하는 경우 BAD_REQUEST를 반환한다 +- [ ] 제외 좌석과 등록 좌석 전체가 section의 모든 좌석과 다른 경우 BAD_REQUEST를 반환한다 비고 From 1ae45b4565749daba6f01c7b0caf624ec38d6577 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 19 Oct 2025 16:21:26 +0900 Subject: [PATCH 02/38] add seat usage request to ShowScheduleRegisterRequest with validation for sectionId, excludeSeatIds, and gradeAssignments --- .../webapi/show/schedule/POST_specs.java | 23 +++++++- .../show/ShowScheduleRegisterRequest.java | 55 ++++++++++++++++++- 2 files changed, 74 insertions(+), 4 deletions(-) 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..e0fb222 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 @@ -13,10 +13,13 @@ 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; @@ -161,7 +164,8 @@ public class POST_specs { var request = new ShowScheduleRegisterRequest( 9999L,// 존재하지 않는 showId LocalDateTime.of(2025, 9, 10, 19, 0), - LocalDateTime.of(2025, 9, 10, 21, 30) + LocalDateTime.of(2025, 9, 10, 21, 30), + getSeatUsageRequest() ); // Act @@ -184,7 +188,8 @@ public class POST_specs { 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() ); // Act @@ -251,7 +256,19 @@ private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show sho return new ShowScheduleRegisterRequest( show.getId(), startAt, - endAt + endAt, + getSeatUsageRequest() + ); + } + + private static SeatUsageRequest getSeatUsageRequest() { + return new SeatUsageRequest( + 2L, + List.of(), + List.of( + new GradeAssignmentRequest(1L, List.of(1L, 2L, 3L)), + new GradeAssignmentRequest(2L, List.of(4L, 5L, 6L)) + ) ); } } 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..0e9e7f6 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,68 @@ 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.HashSet; +import java.util.List; +import java.util.Set; 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 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 record GradeAssignmentRequest( + @NotNull(message = "gradeId is required") + Long gradeId, + + @NotNull(message = "seatIds are required") + @NotEmpty(message = "seatIds must not be empty") + List seatIds + ) { + } } From d9e81e9acb970a461a47939f042e19a15f02d231 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 19 Oct 2025 16:22:34 +0900 Subject: [PATCH 03/38] add note on inventory seat creation upon successful schedule registration --- docs/specs/api/show_schedule_register.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 399284d..3f445b8 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -81,6 +81,7 @@ - [ ] gradeAssignments의 seatIds에 해당 hall의 seat id가 존재하지 않는 경우 NOT_FOUND를 반환한다 - [ ] gradeAssignments의 seatIds에 중복된 좌석이 존재하는 경우 BAD_REQUEST를 반환한다 - [ ] 제외 좌석과 등록 좌석 전체가 section의 모든 좌석과 다른 경우 BAD_REQUEST를 반환한다 +- [ ] 일정이 정상적으로 등록된 경우 inventory에 해당 회차의 좌석이 모두 생성된다 비고 From 4f545171f1c42e7d3523b86644d30b4b44168a6f Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 19 Oct 2025 16:38:13 +0900 Subject: [PATCH 04/38] add section existence check in hall validation and repository --- .../booking/app/hall/HallQueryRepository.java | 4 +++ .../booking/app/hall/HallService.java | 7 ++++ .../booking/app/hall/HallValidator.java | 2 ++ .../booking/app/show/ShowService.java | 1 + .../webapi/show/schedule/POST_specs.java | 34 +++++++++++++++++++ .../mandarin/booking/domain/hall/Hall.java | 4 +++ 6 files changed, 52 insertions(+) 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..c07c4db 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 @@ -24,4 +24,8 @@ Hall findById(Long hallId) { return repository.findById(hallId) .orElseThrow(() -> new HallException("NOT_FOUND", "해당 공연장을 찾을 수 없습니다.")); } + + boolean existsByHallIdAndSectionId(Long hallId, Long sectionId) { + return findById(hallId).hasSectionOf(sectionId); + } } 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..076e755 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 @@ -32,6 +32,13 @@ 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 HallRegisterResponse register(String userId, HallRegisterRequest request) { checkHallExistByHallName(request.hallName()); 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..fc63fe6 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 @@ -4,4 +4,6 @@ public interface HallValidator { void checkHallExistByHallId(Long hallId); void checkHallExistByHallName(String hallName); + + void checkHallExistBySectionId(Long hallId, Long sectionId); } 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..f3ac9e0 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 @@ -47,6 +47,7 @@ public ShowRegisterResponse register(ShowRegisterRequest request) { @Override public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest request) { var show = queryRepository.findById(request.showId()); + hallValidator.checkHallExistBySectionId(show.getHallId(), request.use().sectionId()); checkConflictSchedule(show.getHallId(), request); var command = new ShowScheduleCreateCommand(request.showId(), request.startAt(), request.endAt()); 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 e0fb222..d71237b 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 @@ -244,6 +244,29 @@ public class POST_specs { assertThat(response.getData()).contains("해당 회차는 이미 공연 스케줄이 등록되어 있습니다."); } + @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 request = new ShowScheduleRegisterRequest( + show.getId(), + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30), + getSeatUsageRequest(9999L) // 존재하지 않는 sectionId + ); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(NOT_FOUND); + } + private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show) { return generateShowScheduleRegisterRequest(show, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30)); @@ -261,6 +284,17 @@ private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show sho ); } + private static SeatUsageRequest getSeatUsageRequest(long sectionId) { + return new SeatUsageRequest( + sectionId, + List.of(), + List.of( + new GradeAssignmentRequest(1L, List.of(1L, 2L, 3L)), + new GradeAssignmentRequest(2L, List.of(4L, 5L, 6L)) + ) + ); + } + private static SeatUsageRequest getSeatUsageRequest() { return new SeatUsageRequest( 2L, 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..3141ef0 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 @@ -33,4 +33,8 @@ public Hall(String hallName, String registantId) { public static Hall create(String name, String registantId) { return new Hall(name, registantId); } + + public boolean hasSectionOf(Long sectionId) { + return sections.stream().anyMatch(section -> section.getId().equals(sectionId)); + } } From b473199f15fa37fe1f5abaa3ff7f02fb2ab61639 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 19 Oct 2025 16:38:21 +0900 Subject: [PATCH 05/38] add section existence check in hall validation and repository --- docs/specs/api/show_schedule_register.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 3f445b8..a2c849c 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -74,7 +74,7 @@ - [x] 존재하지 않는 showId를 보내면 NOT_FOUND 상태코드를 반환한다 - [x] 공연 기간 범위를 벗어나는 startAt 또는 endAt을 보낼 경우 BAD_REQUEST를 반환한다 - [x] 동일한 hallId와 시간이 겹치는 회차를 등록하려 하면 INTERNAL_SERVER_ERROR를 반환한다 -- [ ] showId에 해당하는 hall에 해당하는 sectionId를 찾을 수 없으면 NOT_FOUND를 반환한다 +- [x] showId에 해당하는 hall에 해당하는 sectionId를 찾을 수 없으면 NOT_FOUND를 반환한다 - [ ] excludeSeatIds에 해당 section의 id가 아닌 좌석 id가 포함되면 NOT_FOUND를 반환한다 - [ ] excludeSeatIds에 중복된 좌석이 있는 경우 BAD_REQUEST를 반환한다 - [ ] gradeAssignments의 gradeId가 해당 show에 존재하지 않으면 NOT_FOUND를 반환한다 From fae03a95a40236e2558ceec7d852c957372d7d1e Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 22 Oct 2025 00:59:34 +0900 Subject: [PATCH 06/38] add custom Hibernate naming strategy for table and column naming conventions --- .../app/TableAwarePhysicalNamingStrategy.java | 46 +++++++++++++++++++ .../src/main/resources/application.yml | 4 ++ 2 files changed, 50 insertions(+) create mode 100644 application/src/main/java/org/mandarin/booking/app/TableAwarePhysicalNamingStrategy.java 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..a1995cf --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/TableAwarePhysicalNamingStrategy.java @@ -0,0 +1,46 @@ +package org.mandarin.booking.app; + +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 table = CURRENT_TABLE.get(); + var physical = "id".equalsIgnoreCase(logical) + ? (table == null || table.isBlank() ? "id" : table + "_id") + : toSnakeCase(logical); + return Identifier.toIdentifier(physical, name.isQuoted()); + } + + private static String toSnakeCase(String s) { + if (s.isEmpty()) { + return s; + } + var n = s.length(); + var sb = new StringBuilder(n + 8); + for (int i = 0; i < n; i++) { + var c = s.charAt(i); + if (Character.isUpperCase(c) + && i > 0 + && (Character.isLowerCase(s.charAt(i - 1)) || (i + 1 < n && Character.isLowerCase(s.charAt(i + 1))))) { + sb.append('_'); + } + sb.append(Character.toLowerCase(c)); + } + return sb.toString(); + } +} 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 From ab5f73893cbb4f106f72dbbc3d247051e10dbe83 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 22 Oct 2025 01:07:58 +0900 Subject: [PATCH 07/38] add seat row and number fields to Seat entity with a factory method for creation --- .../main/java/org/mandarin/booking/domain/hall/Seat.java | 7 +++++++ 1 file changed, 7 insertions(+) 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..d18a203 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; @@ -22,7 +23,13 @@ class Seat extends AbstractEntity { @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); + } } From b5f31b9ebc8d8e88f8b897538ab4bafe7c7616de Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 22 Oct 2025 01:46:30 +0900 Subject: [PATCH 08/38] add section creation with seats during hall registration and validate seat IDs in show scheduling --- .../booking/app/hall/HallQueryRepository.java | 21 +++++ .../booking/app/hall/HallService.java | 11 ++- .../booking/app/hall/HallValidator.java | 4 + .../booking/app/show/ShowService.java | 1 + .../mandarin/booking/utils/HallFixture.java | 18 ++++ .../mandarin/booking/utils/ShowFixture.java | 17 ++++ .../mandarin/booking/utils/TestFixture.java | 29 ++++--- .../webapi/show/schedule/POST_specs.java | 82 ++++++++++++++----- docs/specs/api/show_schedule_register.md | 2 +- .../mandarin/booking/domain/hall/Hall.java | 9 +- .../mandarin/booking/domain/hall/Section.java | 14 ++++ 11 files changed, 170 insertions(+), 38 deletions(-) create mode 100644 application/src/test/java/org/mandarin/booking/utils/ShowFixture.java 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 c07c4db..27d0e50 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 com.querydsl.core.types.ExpressionUtils.count; +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.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); @@ -28,4 +35,18 @@ Hall findById(Long hallId) { boolean existsByHallIdAndSectionId(Long hallId, Long sectionId) { return findById(hallId).hasSectionOf(sectionId); } + + boolean containsSeatIdsBySectionId(List excludeSeatIds, Long sectionId) { + if (excludeSeatIds.isEmpty()) { + return true; + } + var count = jpaQueryFactory + .select(count(seat.id)) + .from(section) + .join(section.seats, seat).on(seat.id.in(excludeSeatIds)) + .where(section.id.eq(sectionId)) + .fetchFirst(); + + return count != null && count.equals((long) excludeSeatIds.size()); + } } 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 076e755..086b81e 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; @@ -39,11 +40,19 @@ public void checkHallExistBySectionId(Long hallId, Long sectionId) { } } + @Override + public void checkHallInvalidSeatIds(List seatIds, Long sectionId) { + var areSeatIdsValid = queryRepository.containsSeatIdsBySectionId(seatIds, sectionId); + if (!areSeatIdsValid) { + 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 fc63fe6..c9d41eb 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,9 +1,13 @@ 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(List excludeSeatIds, Long sectionId); } 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 f3ac9e0..1bfec28 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 @@ -48,6 +48,7 @@ public ShowRegisterResponse register(ShowRegisterRequest request) { public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest request) { var show = queryRepository.findById(request.showId()); hallValidator.checkHallExistBySectionId(show.getHallId(), request.use().sectionId()); + hallValidator.checkHallInvalidSeatIds(request.use().excludeSeatIds(), request.use().sectionId()); checkConflictSchedule(show.getHallId(), request); var command = new ShowScheduleCreateCommand(request.showId(), request.startAt(), request.endAt()); 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..2218fb5 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 count) { + return List.of( + new SectionRegisterRequest( + UUID.randomUUID().toString().substring(0, 10), + IntStream.range(0, count) + .mapToObj(i -> new SeatRegisterRequest( + UUID.randomUUID().toString().substring(0, 8), + UUID.randomUUID().toString().substring(0, 8) + )) + .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..9f8239d --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/utils/ShowFixture.java @@ -0,0 +1,17 @@ +package org.mandarin.booking.utils; + +import java.time.LocalDateTime; +import java.util.Random; +import org.mandarin.booking.domain.show.Show; +import org.mandarin.booking.domain.show.ShowScheduleCreateCommand; + +public class ShowFixture { + static ShowScheduleCreateCommand generateShowScheduleCreateCommand(Show show) { + Random random = new Random(); + var startAt = LocalDateTime.now().plusDays(random.nextInt(0, 10)); + return new ShowScheduleCreateCommand(show.getId(), + startAt, + startAt.plusHours(random.nextInt(2, 5)) + ); + } +} 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..4e484a4 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -3,20 +3,22 @@ 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.List; import java.util.Random; import java.util.UUID; import java.util.stream.IntStream; import org.mandarin.booking.MemberAuthority; 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; @@ -27,7 +29,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; @@ -107,7 +108,8 @@ public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanc } public Hall insertDummyHall(String userId) { - var hall = Hall.create(generateHallName(), userId); + List sections = generateSectionRegisterRequest(10); + var hall = Hall.create(generateHallName(), sections, userId); entityManager.persist(hall); return hall; } @@ -117,12 +119,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); } @@ -221,12 +218,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); } @@ -275,6 +267,13 @@ 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(); + } + 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)); 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 d71237b..83bfb48 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 @@ -37,8 +37,11 @@ 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 request = generateShowScheduleRegisterRequest( show, + sectionId, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30)); @@ -58,8 +61,11 @@ 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 request = generateShowScheduleRegisterRequest( show, + sectionId, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30)); @@ -79,8 +85,11 @@ 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 request = generateShowScheduleRegisterRequest( show, + sectionId, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30)); @@ -100,7 +109,9 @@ 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 request = generateShowScheduleRegisterRequest(show, sectionId); // Act var response = testUtils.post("/api/show/schedule", request) @@ -118,8 +129,10 @@ 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 now = LocalDateTime.now(); - var request = generateShowScheduleRegisterRequest(show, now, now.minusMinutes(1)); + var request = generateShowScheduleRegisterRequest(show, sectionId, now, now.minusMinutes(1)); // Act var response = testUtils.post("/api/show/schedule", request) @@ -139,7 +152,10 @@ 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 request = generateShowScheduleRegisterRequest(show, + sectionId, LocalDateTime.of(2025, 9, 10, 21, 30), LocalDateTime.of(2025, 9, 10, 19, 0) ); @@ -160,12 +176,14 @@ public class POST_specs { @Autowired TestFixture testFixture ) { // Arrange - testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + 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 request = new ShowScheduleRegisterRequest( 9999L,// 존재하지 않는 showId LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30), - getSeatUsageRequest() + getSeatUsageRequest(sectionId) ); // Act @@ -185,11 +203,13 @@ 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 request = new ShowScheduleRegisterRequest( requireNonNull(show.getId()), LocalDateTime.of(2023, 9, 10, 19, 0), LocalDateTime.of(2023, 9, 10, 21, 30), - getSeatUsageRequest() + getSeatUsageRequest(sectionId) ); // Act @@ -212,14 +232,18 @@ 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 request = generateShowScheduleRegisterRequest( show, + sectionId, LocalDateTime.now(), LocalDateTime.now().plusHours(2) ); var nextRequest = generateShowScheduleRegisterRequest( show, + sectionId, LocalDateTime.now().plusHours(1), LocalDateTime.now().plusHours(3) ); @@ -267,20 +291,51 @@ public class POST_specs { assertThat(response.getStatus()).isEqualTo(NOT_FOUND); } - private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show) { - return generateShowScheduleRegisterRequest(show, LocalDateTime.of(2025, 9, 10, 19, 0), + @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 invalidSeatId = 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(invalidSeatId), + List.of(new GradeAssignmentRequest(1L, List.of())) + ) + ); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, Long sectionId) { + return generateShowScheduleRegisterRequest(show, sectionId, + LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30)); } private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, + Long sectionId, LocalDateTime startAt, LocalDateTime endAt) { return new ShowScheduleRegisterRequest( show.getId(), startAt, endAt, - getSeatUsageRequest() + getSeatUsageRequest(sectionId) ); } @@ -294,15 +349,4 @@ private static SeatUsageRequest getSeatUsageRequest(long sectionId) { ) ); } - - private static SeatUsageRequest getSeatUsageRequest() { - return new SeatUsageRequest( - 2L, - List.of(), - List.of( - new GradeAssignmentRequest(1L, List.of(1L, 2L, 3L)), - new GradeAssignmentRequest(2L, List.of(4L, 5L, 6L)) - ) - ); - } } diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index a2c849c..de21483 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -75,7 +75,7 @@ - [x] 공연 기간 범위를 벗어나는 startAt 또는 endAt을 보낼 경우 BAD_REQUEST를 반환한다 - [x] 동일한 hallId와 시간이 겹치는 회차를 등록하려 하면 INTERNAL_SERVER_ERROR를 반환한다 - [x] showId에 해당하는 hall에 해당하는 sectionId를 찾을 수 없으면 NOT_FOUND를 반환한다 -- [ ] excludeSeatIds에 해당 section의 id가 아닌 좌석 id가 포함되면 NOT_FOUND를 반환한다 +- [x] excludeSeatIds에 해당 section의 id가 아닌 좌석 id가 포함되면 NOT_FOUND를 반환한다 - [ ] excludeSeatIds에 중복된 좌석이 있는 경우 BAD_REQUEST를 반환한다 - [ ] gradeAssignments의 gradeId가 해당 show에 존재하지 않으면 NOT_FOUND를 반환한다 - [ ] gradeAssignments의 seatIds에 해당 hall의 seat id가 존재하지 않는 경우 NOT_FOUND를 반환한다 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 3141ef0..608e929 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,8 +32,11 @@ public Hall(String hallName, String registantId) { this.registantId = registantId; } - public static Hall create(String name, String registantId) { - return new Hall(name, registantId); + 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 boolean hasSectionOf(Long sectionId) { 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..65eb66d 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 @@ -30,4 +30,18 @@ class Section extends AbstractEntity { 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; + } } From 1ba33e78ac142d516a6410450efc0587040be22a Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 22 Oct 2025 10:48:34 +0900 Subject: [PATCH 09/38] add test for handling duplicate seat IDs in show schedule registration --- .../mandarin/booking/utils/ShowFixture.java | 35 ++++++++++++- .../webapi/show/schedule/POST_specs.java | 51 +++++++++---------- docs/specs/api/show_schedule_register.md | 2 +- 3 files changed, 60 insertions(+), 28 deletions(-) diff --git a/application/src/test/java/org/mandarin/booking/utils/ShowFixture.java b/application/src/test/java/org/mandarin/booking/utils/ShowFixture.java index 9f8239d..19897e7 100644 --- a/application/src/test/java/org/mandarin/booking/utils/ShowFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/ShowFixture.java @@ -1,17 +1,50 @@ package org.mandarin.booking.utils; import java.time.LocalDateTime; +import java.util.List; import java.util.Random; import org.mandarin.booking.domain.show.Show; 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(); static ShowScheduleCreateCommand generateShowScheduleCreateCommand(Show show) { - Random random = new Random(); var startAt = LocalDateTime.now().plusDays(random.nextInt(0, 10)); return new ShowScheduleCreateCommand(show.getId(), startAt, startAt.plusHours(random.nextInt(2, 5)) ); } + + public static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, Long sectionId) { + return generateShowScheduleRegisterRequest(show, sectionId, + LocalDateTime.of(2025, 9, 10, 19, 0), + LocalDateTime.of(2025, 9, 10, 21, 30)); + } + + public static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, + Long sectionId, + LocalDateTime startAt, + LocalDateTime endAt) { + return new ShowScheduleRegisterRequest( + show.getId(), + startAt, + endAt, + getSeatUsageRequest(sectionId) + ); + } + + public static SeatUsageRequest getSeatUsageRequest(long sectionId) { + return new SeatUsageRequest( + sectionId, + List.of(), + List.of( + new GradeAssignmentRequest(1L, List.of(1L, 2L, 3L)), + new GradeAssignmentRequest(2L, List.of(4L, 5L, 6L)) + ) + ); + } } 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 83bfb48..fea700d 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 @@ -10,13 +10,14 @@ 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; @@ -320,33 +321,31 @@ public class POST_specs { assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); } - private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, Long sectionId) { - return generateShowScheduleRegisterRequest(show, sectionId, - LocalDateTime.of(2025, 9, 10, 19, 0), - LocalDateTime.of(2025, 9, 10, 21, 30)); - } - - - private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, - Long sectionId, - LocalDateTime startAt, - LocalDateTime endAt) { - return new ShowScheduleRegisterRequest( + @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(), - startAt, - endAt, - getSeatUsageRequest(sectionId) - ); - } - - private static SeatUsageRequest getSeatUsageRequest(long sectionId) { - return new SeatUsageRequest( - sectionId, - List.of(), - List.of( - new GradeAssignmentRequest(1L, List.of(1L, 2L, 3L)), - new GradeAssignmentRequest(2L, List.of(4L, 5L, 6L)) + 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())) ) ); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); } } diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index de21483..9bfacde 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -76,7 +76,7 @@ - [x] 동일한 hallId와 시간이 겹치는 회차를 등록하려 하면 INTERNAL_SERVER_ERROR를 반환한다 - [x] showId에 해당하는 hall에 해당하는 sectionId를 찾을 수 없으면 NOT_FOUND를 반환한다 - [x] excludeSeatIds에 해당 section의 id가 아닌 좌석 id가 포함되면 NOT_FOUND를 반환한다 -- [ ] excludeSeatIds에 중복된 좌석이 있는 경우 BAD_REQUEST를 반환한다 +- [x] excludeSeatIds에 중복된 좌석이 있는 경우 BAD_REQUEST를 반환한다 - [ ] gradeAssignments의 gradeId가 해당 show에 존재하지 않으면 NOT_FOUND를 반환한다 - [ ] gradeAssignments의 seatIds에 해당 hall의 seat id가 존재하지 않는 경우 NOT_FOUND를 반환한다 - [ ] gradeAssignments의 seatIds에 중복된 좌석이 존재하는 경우 BAD_REQUEST를 반환한다 From dd9e4fb9dc09064f76c708d886f843082ad95cd2 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 22 Oct 2025 11:11:01 +0900 Subject: [PATCH 10/38] add validation for grade IDs in show schedule registration --- .../booking/app/show/ShowService.java | 2 ++ .../mandarin/booking/utils/TestFixture.java | 9 ++++++ .../webapi/show/schedule/POST_specs.java | 30 +++++++++++++++++++ docs/specs/api/show_schedule_register.md | 1 + .../mandarin/booking/domain/show/Show.java | 14 ++++++++- 5 files changed, 55 insertions(+), 1 deletion(-) 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 1bfec28..8f0af01 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; @@ -49,6 +50,7 @@ public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest var show = queryRepository.findById(request.showId()); hallValidator.checkHallExistBySectionId(show.getHallId(), request.use().sectionId()); hallValidator.checkHallInvalidSeatIds(request.use().excludeSeatIds(), 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()); 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 4e484a4..6cf8fcc 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -321,4 +321,13 @@ private Member memberInsert(Member member) { entityManager.persist(member); return member; } + + 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(); + } } 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 fea700d..e4b1db3 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 @@ -348,4 +348,34 @@ public class POST_specs { // Assert assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); } + + @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); + } } diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 9bfacde..baf3cdf 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -78,6 +78,7 @@ - [x] excludeSeatIds에 해당 section의 id가 아닌 좌석 id가 포함되면 NOT_FOUND를 반환한다 - [x] excludeSeatIds에 중복된 좌석이 있는 경우 BAD_REQUEST를 반환한다 - [ ] gradeAssignments의 gradeId가 해당 show에 존재하지 않으면 NOT_FOUND를 반환한다 +- [ ] gradeAssignments의 gradeId가 중복된 경우 BAD_REQUEST를 반환한다 - [ ] gradeAssignments의 seatIds에 해당 hall의 seat id가 존재하지 않는 경우 NOT_FOUND를 반환한다 - [ ] gradeAssignments의 seatIds에 중복된 좌석이 존재하는 경우 BAD_REQUEST를 반환한다 - [ ] 제외 좌석과 등록 좌석 전체가 section의 모든 좌석과 다른 경우 BAD_REQUEST를 반환한다 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..4a54d28 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; @@ -127,6 +129,14 @@ 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", "해당하는 등급이 존재하지 않습니다."); + } + } + private void addGrades(List grades) { this.grades.addAll(grades); } @@ -136,17 +146,19 @@ private boolean isInSchedule(LocalDateTime scheduleStartAt, LocalDateTime schedu && scheduleEndAt.isBefore(performanceEndDate.atStartOfDay()); } + public enum Type { MUSICAL, PLAY, CONCERT, OPERA, DANCE, CLASSICAL, ETC + } public enum Rating { ALL, AGE12, AGE15, AGE18 } - @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) public static class ShowCreateCommand { + private final String title; private final Type type; private final Rating rating; From ab140232d2bc28922a053960af74423ab42f7a1c Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 22 Oct 2025 11:11:19 +0900 Subject: [PATCH 11/38] add validation for grade IDs in show schedule registration --- docs/specs/api/show_schedule_register.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index baf3cdf..53a62c5 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -77,7 +77,7 @@ - [x] showId에 해당하는 hall에 해당하는 sectionId를 찾을 수 없으면 NOT_FOUND를 반환한다 - [x] excludeSeatIds에 해당 section의 id가 아닌 좌석 id가 포함되면 NOT_FOUND를 반환한다 - [x] excludeSeatIds에 중복된 좌석이 있는 경우 BAD_REQUEST를 반환한다 -- [ ] gradeAssignments의 gradeId가 해당 show에 존재하지 않으면 NOT_FOUND를 반환한다 +- [x] gradeAssignments의 gradeId가 해당 show에 존재하지 않으면 NOT_FOUND를 반환한다 - [ ] gradeAssignments의 gradeId가 중복된 경우 BAD_REQUEST를 반환한다 - [ ] gradeAssignments의 seatIds에 해당 hall의 seat id가 존재하지 않는 경우 NOT_FOUND를 반환한다 - [ ] gradeAssignments의 seatIds에 중복된 좌석이 존재하는 경우 BAD_REQUEST를 반환한다 From 76560b32c697cf56e362a485c70222bdc5180961 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 22 Oct 2025 22:58:56 +0900 Subject: [PATCH 12/38] add grade seat mapping in show schedule registration --- .../mandarin/booking/utils/ShowFixture.java | 34 ++++++++--- .../mandarin/booking/utils/TestFixture.java | 58 ++++++++++++++++--- .../webapi/show/schedule/POST_specs.java | 46 ++++++++++----- 3 files changed, 108 insertions(+), 30 deletions(-) diff --git a/application/src/test/java/org/mandarin/booking/utils/ShowFixture.java b/application/src/test/java/org/mandarin/booking/utils/ShowFixture.java index 19897e7..6b3249a 100644 --- a/application/src/test/java/org/mandarin/booking/utils/ShowFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/ShowFixture.java @@ -2,8 +2,12 @@ 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; @@ -11,6 +15,7 @@ public class ShowFixture { private static final Random random = new Random(); + static ShowScheduleCreateCommand generateShowScheduleCreateCommand(Show show) { var startAt = LocalDateTime.now().plusDays(random.nextInt(0, 10)); return new ShowScheduleCreateCommand(show.getId(), @@ -19,32 +24,43 @@ static ShowScheduleCreateCommand generateShowScheduleCreateCommand(Show show) { ); } - public static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, Long sectionId) { + public static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, Long sectionId, + Map> gradeSeatMap) { return generateShowScheduleRegisterRequest(show, sectionId, LocalDateTime.of(2025, 9, 10, 19, 0), - LocalDateTime.of(2025, 9, 10, 21, 30)); + LocalDateTime.of(2025, 9, 10, 21, 30), gradeSeatMap); } public static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, Long sectionId, LocalDateTime startAt, - LocalDateTime endAt) { + LocalDateTime endAt, + Map> gradeSeatMap) { return new ShowScheduleRegisterRequest( show.getId(), startAt, endAt, - getSeatUsageRequest(sectionId) + getSeatUsageRequest(sectionId, gradeSeatMap) ); } - public static SeatUsageRequest getSeatUsageRequest(long sectionId) { + public static SeatUsageRequest getSeatUsageRequest(long sectionId, Map> gradeSeatMap) { return new SeatUsageRequest( sectionId, List.of(), - List.of( - new GradeAssignmentRequest(1L, List.of(1L, 2L, 3L)), - new GradeAssignmentRequest(2L, List.of(4L, 5L, 6L)) - ) + gradeSeatMap.entrySet().stream() + .map(entry -> new GradeAssignmentRequest(entry.getKey(), entry.getValue())) + .toList() ); } + + 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/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java index 6cf8fcc..451f6c2 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -8,11 +8,14 @@ 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.generateGradeRequest; import static org.mandarin.booking.utils.ShowFixture.generateShowScheduleCreateCommand; import jakarta.persistence.EntityManager; import java.time.LocalDate; +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; @@ -176,7 +179,7 @@ public void generateShows(int showCount, int before, int after) { LocalDate.now().minusDays(random.nextInt(before)), LocalDate.now().plusDays(random.nextInt(after)), "KRW", - List.of(new ShowRegisterRequest.GradeRequest("VIP", 100000, 100)) + generateGradeRequest(5) ); var show = Show.create(hallId, ShowCreateCommand.from(request)); showInsert(show); @@ -274,6 +277,50 @@ public List findSectionIdsByHallId(Long 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) { + // grade id 가져오기 + var gradeIds = findGradeIdsByShowId(showId); + // seat id 가져오기 + var hall = findHallById(showId); + var seatIds = entityManager.createQuery("SELECT seat.id FROM Seat seat WHERE seat.section.hall.id = :hallId", + Long.class) + .setParameter("hallId", hall.getId()) + .getResultList(); + + // seatIds를 gradeIds의 개수만큼 분할하여 매핑 + return gerateGradeSeatMap(gradeIds, seatIds); + } + + 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)); @@ -322,12 +369,9 @@ private Member memberInsert(Member member) { return member; } - 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) + 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(); } } 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 e4b1db3..80d911a 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 @@ -16,6 +16,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; @@ -40,11 +41,12 @@ public class POST_specs { 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(); + Map> gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId()); var request = generateShowScheduleRegisterRequest( show, 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) @@ -64,11 +66,12 @@ public class POST_specs { 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()); var request = generateShowScheduleRegisterRequest( show, 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) @@ -88,11 +91,12 @@ public class POST_specs { 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()); var request = generateShowScheduleRegisterRequest( show, 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) @@ -112,7 +116,8 @@ public class POST_specs { 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 request = generateShowScheduleRegisterRequest(show, sectionId); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId()); + var request = generateShowScheduleRegisterRequest(show, sectionId, gradeSeatMap); // Act var response = testUtils.post("/api/show/schedule", request) @@ -132,8 +137,9 @@ public class POST_specs { 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()); var now = LocalDateTime.now(); - var request = generateShowScheduleRegisterRequest(show, sectionId, now, now.minusMinutes(1)); + var request = generateShowScheduleRegisterRequest(show, sectionId, now, now.minusMinutes(1), gradeSeatMap); // Act var response = testUtils.post("/api/show/schedule", request) @@ -155,11 +161,12 @@ public class POST_specs { 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()); var request = generateShowScheduleRegisterRequest(show, 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) @@ -180,11 +187,12 @@ public class POST_specs { 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()); var request = new ShowScheduleRegisterRequest( 9999L,// 존재하지 않는 showId LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30), - getSeatUsageRequest(sectionId) + getSeatUsageRequest(sectionId, gradeSeatMap) ); // Act @@ -206,11 +214,12 @@ public class POST_specs { 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()); var request = new ShowScheduleRegisterRequest( requireNonNull(show.getId()), LocalDateTime.of(2023, 9, 10, 19, 0), LocalDateTime.of(2023, 9, 10, 21, 30), - getSeatUsageRequest(sectionId) + getSeatUsageRequest(sectionId, gradeSeatMap) ); // Act @@ -235,19 +244,20 @@ public class POST_specs { ); var hallId = show.getHallId(); var sectionId = testFixture.findSectionIdsByHallId(hallId).stream().findFirst().get(); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId()); var request = generateShowScheduleRegisterRequest( show, sectionId, LocalDateTime.now(), - LocalDateTime.now().plusHours(2) - ); + LocalDateTime.now().plusHours(2), + gradeSeatMap); var nextRequest = generateShowScheduleRegisterRequest( show, sectionId, LocalDateTime.now().plusHours(1), - LocalDateTime.now().plusHours(3) - ); + LocalDateTime.now().plusHours(3), + gradeSeatMap); testUtils.post( "/api/show/schedule", @@ -276,11 +286,19 @@ public class POST_specs { ) { // Arrange var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId()); + var sectionId = 9999L; var request = new ShowScheduleRegisterRequest( show.getId(), LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30), - getSeatUsageRequest(9999L) // 존재하지 않는 sectionId + new SeatUsageRequest( + sectionId,// 존재하지 않는 sectionId + List.of(), + gradeSeatMap.entrySet().stream() + .map(entry -> new GradeAssignmentRequest(entry.getKey(), entry.getValue())) + .toList() + ) ); // Act From 050fa3f0d1ccbcd96383247ee48026e2d61c4446 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 22 Oct 2025 23:08:57 +0900 Subject: [PATCH 13/38] add validation to ensure grade IDs are unique in show schedule registration --- .../webapi/show/schedule/POST_specs.java | 30 +++++++++++++++++++ docs/specs/api/show_schedule_register.md | 2 +- .../show/ShowScheduleRegisterRequest.java | 8 +++++ 3 files changed, 39 insertions(+), 1 deletion(-) 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 80d911a..e2d5ba7 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 @@ -396,4 +396,34 @@ public class POST_specs { // 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); + 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, seatIds), new GradeAssignmentRequest(1L, seatIds)) + ) + ); + + // 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"); + } } diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 53a62c5..5ecb36d 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -78,7 +78,7 @@ - [x] excludeSeatIds에 해당 section의 id가 아닌 좌석 id가 포함되면 NOT_FOUND를 반환한다 - [x] excludeSeatIds에 중복된 좌석이 있는 경우 BAD_REQUEST를 반환한다 - [x] gradeAssignments의 gradeId가 해당 show에 존재하지 않으면 NOT_FOUND를 반환한다 -- [ ] gradeAssignments의 gradeId가 중복된 경우 BAD_REQUEST를 반환한다 +- [x] gradeAssignments의 gradeId가 중복된 경우 BAD_REQUEST를 반환한다 - [ ] gradeAssignments의 seatIds에 해당 hall의 seat id가 존재하지 않는 경우 NOT_FOUND를 반환한다 - [ ] gradeAssignments의 seatIds에 중복된 좌석이 존재하는 경우 BAD_REQUEST를 반환한다 - [ ] 제외 좌석과 등록 좌석 전체가 section의 모든 좌석과 다른 경우 BAD_REQUEST를 반환한다 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 0e9e7f6..5477b85 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 @@ -47,6 +47,14 @@ public boolean hasUniqueExcludeSeatIds() { 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<>(); From bbc7a745d980056f010e1c7e5283c99dfe023110 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 22 Oct 2025 23:36:52 +0900 Subject: [PATCH 14/38] add validation to return BAD_REQUEST for invalid seat IDs in gradeAssignments --- .../booking/app/show/ShowService.java | 4 +++ .../webapi/show/schedule/POST_specs.java | 34 +++++++++++++++++++ docs/specs/api/show_schedule_register.md | 2 +- 3 files changed, 39 insertions(+), 1 deletion(-) 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 8f0af01..035fbd8 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 @@ -50,6 +50,10 @@ public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest var show = queryRepository.findById(request.showId()); hallValidator.checkHallExistBySectionId(show.getHallId(), request.use().sectionId()); hallValidator.checkHallInvalidSeatIds(request.use().excludeSeatIds(), request.use().sectionId()); + hallValidator.checkHallInvalidSeatIds(request.use().gradeAssignments().stream() + .flatMap(gradeAssignmentRequest -> gradeAssignmentRequest.seatIds().stream()) + .toList(), + request.use().sectionId()); show.validateGradeIds(request.use().gradeAssignments().stream().map(GradeAssignmentRequest::gradeId).toList()); checkConflictSchedule(show.getHallId(), request); 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 e2d5ba7..07044f8 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 @@ -426,4 +426,38 @@ public class POST_specs { 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 seatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId()); + var invalidSeatId = 9999L; + seatMap.entrySet().stream().findFirst().ifPresent(entry -> entry.getValue().add(invalidSeatId)); + + 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(), + seatMap.entrySet().stream() + .map(entry -> new GradeAssignmentRequest(entry.getKey(), entry.getValue())) + .toList() + ) + ); + + // Act + var response = testUtils.post("/api/show/schedule", request) + .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } } diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 5ecb36d..49568a0 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -79,7 +79,7 @@ - [x] excludeSeatIds에 중복된 좌석이 있는 경우 BAD_REQUEST를 반환한다 - [x] gradeAssignments의 gradeId가 해당 show에 존재하지 않으면 NOT_FOUND를 반환한다 - [x] gradeAssignments의 gradeId가 중복된 경우 BAD_REQUEST를 반환한다 -- [ ] gradeAssignments의 seatIds에 해당 hall의 seat id가 존재하지 않는 경우 NOT_FOUND를 반환한다 +- [x] gradeAssignments의 seatIds에 해당 hall의 seat id가 존재하지 않는 경우 BAD_REQUEST 반환한다 - [ ] gradeAssignments의 seatIds에 중복된 좌석이 존재하는 경우 BAD_REQUEST를 반환한다 - [ ] 제외 좌석과 등록 좌석 전체가 section의 모든 좌석과 다른 경우 BAD_REQUEST를 반환한다 - [ ] 일정이 정상적으로 등록된 경우 inventory에 해당 회차의 좌석이 모두 생성된다 From a210ca175de5479455e6ea7085ff34388a4bf64c Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 24 Oct 2025 14:19:39 +0900 Subject: [PATCH 15/38] add validation to return BAD_REQUEST for duplicate seat IDs in gradeAssignments --- .../webapi/show/schedule/POST_specs.java | 40 +++++++++++++++++++ docs/specs/api/show_schedule_register.md | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) 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 07044f8..7906f8b 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 @@ -460,4 +460,44 @@ public class POST_specs { // 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 seatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId()); + seatMap.entrySet().stream() + .findFirst() + .ifPresent(entry -> { + var firstSeatId = entry.getValue().getFirst(); + entry.getValue().add(firstSeatId); + }); + 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(), + seatMap.entrySet().stream() + .map(entry -> new GradeAssignmentRequest(entry.getKey(), entry.getValue())) + .toList() + ) + + ); + + // 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 seatIds must not contain duplicates across all assignments"); + } } diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 49568a0..681bf37 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -80,7 +80,7 @@ - [x] gradeAssignments의 gradeId가 해당 show에 존재하지 않으면 NOT_FOUND를 반환한다 - [x] gradeAssignments의 gradeId가 중복된 경우 BAD_REQUEST를 반환한다 - [x] gradeAssignments의 seatIds에 해당 hall의 seat id가 존재하지 않는 경우 BAD_REQUEST 반환한다 -- [ ] gradeAssignments의 seatIds에 중복된 좌석이 존재하는 경우 BAD_REQUEST를 반환한다 +- [x] gradeAssignments의 seatIds에 중복된 좌석이 존재하는 경우 BAD_REQUEST를 반환한다 - [ ] 제외 좌석과 등록 좌석 전체가 section의 모든 좌석과 다른 경우 BAD_REQUEST를 반환한다 - [ ] 일정이 정상적으로 등록된 경우 inventory에 해당 회차의 좌석이 모두 생성된다 From 3a6a821c9ab094ef5b4d34f9df370469737fbb4b Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 24 Oct 2025 23:38:29 +0900 Subject: [PATCH 16/38] update section and seat registration methods for improved clarity and functionality --- .../booking/app/hall/HallQueryRepository.java | 17 ++-- .../booking/app/hall/HallService.java | 5 +- .../booking/app/hall/HallValidator.java | 2 +- .../booking/app/show/ShowService.java | 13 +-- .../mandarin/booking/utils/HallFixture.java | 14 +-- .../mandarin/booking/utils/ShowFixture.java | 11 ++- .../mandarin/booking/utils/TestFixture.java | 9 +- .../webapi/show/schedule/POST_specs.java | 86 +++++++++++-------- .../show/ShowScheduleRegisterRequest.java | 6 ++ 9 files changed, 94 insertions(+), 69 deletions(-) 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 27d0e50..a0e5dd8 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,10 +1,10 @@ package org.mandarin.booking.app.hall; -import static com.querydsl.core.types.ExpressionUtils.count; 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; @@ -36,17 +36,16 @@ boolean existsByHallIdAndSectionId(Long hallId, Long sectionId) { return findById(hallId).hasSectionOf(sectionId); } - boolean containsSeatIdsBySectionId(List excludeSeatIds, Long sectionId) { - if (excludeSeatIds.isEmpty()) { + boolean containsSeatIdsBySectionId(Long sectionId, List seatIds) { + if (seatIds.isEmpty()) { return true; } - var count = jpaQueryFactory - .select(count(seat.id)) + var fetched = jpaQueryFactory + .select(seat.id) .from(section) - .join(section.seats, seat).on(seat.id.in(excludeSeatIds)) + .join(section.seats, seat) .where(section.id.eq(sectionId)) - .fetchFirst(); - - return count != null && count.equals((long) excludeSeatIds.size()); + .fetch(); + return new HashSet<>(fetched).containsAll(seatIds); } } 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 086b81e..c0c4ce7 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 @@ -41,9 +41,8 @@ public void checkHallExistBySectionId(Long hallId, Long sectionId) { } @Override - public void checkHallInvalidSeatIds(List seatIds, Long sectionId) { - var areSeatIdsValid = queryRepository.containsSeatIdsBySectionId(seatIds, sectionId); - if (!areSeatIdsValid) { + public void checkHallInvalidSeatIds(Long sectionId, List seatIds) { + if (!queryRepository.containsSeatIdsBySectionId(sectionId, seatIds)) { throw new HallException("BAD_REQUEST", "해당 섹션에 존재하지 않는 좌석이 있습니다."); } } 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 c9d41eb..54d91de 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 @@ -9,5 +9,5 @@ public interface HallValidator { void checkHallExistBySectionId(Long hallId, Long sectionId); - void checkHallInvalidSeatIds(List excludeSeatIds, Long sectionId); + void checkHallInvalidSeatIds(Long sectionId, List seatIds); } 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 035fbd8..3508a82 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 @@ -48,12 +48,12 @@ public ShowRegisterResponse register(ShowRegisterRequest request) { @Override public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest request) { var show = queryRepository.findById(request.showId()); - hallValidator.checkHallExistBySectionId(show.getHallId(), request.use().sectionId()); - hallValidator.checkHallInvalidSeatIds(request.use().excludeSeatIds(), request.use().sectionId()); - hallValidator.checkHallInvalidSeatIds(request.use().gradeAssignments().stream() - .flatMap(gradeAssignmentRequest -> gradeAssignmentRequest.seatIds().stream()) - .toList(), - request.use().sectionId()); + var sectionId = request.use().sectionId(); + var excludeSeatIds = request.use().excludeSeatIds(); + var includeSeatIds = request.use().includeSeatIds(); + hallValidator.checkHallExistBySectionId(show.getHallId(), sectionId); + hallValidator.checkHallInvalidSeatIds(sectionId, excludeSeatIds); + hallValidator.checkHallInvalidSeatIds(sectionId, includeSeatIds); show.validateGradeIds(request.use().gradeAssignments().stream().map(GradeAssignmentRequest::gradeId).toList()); checkConflictSchedule(show.getHallId(), request); @@ -64,6 +64,7 @@ public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest return new ShowScheduleRegisterResponse(requireNonNull(saved.getId())); } + @Override public SliceView fetchShows(Integer page, Integer size, String type, String rating, String q, LocalDate from, LocalDate to) { 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 2218fb5..0361519 100644 --- a/application/src/test/java/org/mandarin/booking/utils/HallFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/HallFixture.java @@ -11,17 +11,17 @@ public static String generateHallName() { return UUID.randomUUID().toString().substring(0, 10); } - static List generateSectionRegisterRequest(int count) { - return List.of( - new SectionRegisterRequest( + 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, count) - .mapToObj(i -> new SeatRegisterRequest( + 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 index 6b3249a..9dfc3bf 100644 --- a/application/src/test/java/org/mandarin/booking/utils/ShowFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/ShowFixture.java @@ -24,20 +24,23 @@ static ShowScheduleCreateCommand generateShowScheduleCreateCommand(Show show) { ); } - public static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, Long sectionId, + public static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Long showId, + Long sectionId, Map> gradeSeatMap) { - return generateShowScheduleRegisterRequest(show, sectionId, + return generateShowScheduleRegisterRequest( + showId, + sectionId, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30), gradeSeatMap); } - public static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, + public static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Long showId, Long sectionId, LocalDateTime startAt, LocalDateTime endAt, Map> gradeSeatMap) { return new ShowScheduleRegisterRequest( - show.getId(), + showId, startAt, endAt, getSeatUsageRequest(sectionId, gradeSeatMap) 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 451f6c2..161211a 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -111,7 +111,7 @@ public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanc } public Hall insertDummyHall(String userId) { - List sections = generateSectionRegisterRequest(10); + List sections = generateSectionRegisterRequest(10, 100); var hall = Hall.create(generateHallName(), sections, userId); entityManager.persist(hall); return hall; @@ -286,14 +286,13 @@ public List findSeatIdsBySectionId(long sectionId) { .getResultList(); } - public Map> generateGradeSeatMapByShowIdAndSectionId(Long showId) { + public Map> generateGradeSeatMapByShowIdAndSectionId(Long showId, Long sectionId) { // grade id 가져오기 var gradeIds = findGradeIdsByShowId(showId); // seat id 가져오기 - var hall = findHallById(showId); - var seatIds = entityManager.createQuery("SELECT seat.id FROM Seat seat WHERE seat.section.hall.id = :hallId", + var seatIds = entityManager.createQuery("SELECT seat.id FROM Seat seat WHERE seat.section.id = :sectionId", Long.class) - .setParameter("hallId", hall.getId()) + .setParameter("sectionId", sectionId) .getResultList(); // seatIds를 gradeIds의 개수만큼 분할하여 매핑 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 7906f8b..ce7501a 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 @@ -41,12 +41,14 @@ public class POST_specs { 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(); - Map> gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId()); + 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), gradeSeatMap); + LocalDateTime.of(2025, 9, 10, 21, 30), + gradeSeatMap + ); // Act var response = testUtils.post("/api/show/schedule", request) @@ -66,9 +68,9 @@ public class POST_specs { 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()); + 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), gradeSeatMap); @@ -91,9 +93,9 @@ public class POST_specs { 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()); + 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), gradeSeatMap); @@ -116,8 +118,8 @@ public class POST_specs { 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()); - var request = generateShowScheduleRegisterRequest(show, sectionId, gradeSeatMap); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); + var request = generateShowScheduleRegisterRequest(show.getId(), sectionId, gradeSeatMap); // Act var response = testUtils.post("/api/show/schedule", request) @@ -137,9 +139,14 @@ public class POST_specs { 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()); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); var now = LocalDateTime.now(); - var request = generateShowScheduleRegisterRequest(show, sectionId, now, now.minusMinutes(1), gradeSeatMap); + var request = generateShowScheduleRegisterRequest( + show.getId(), + sectionId, + now, + now.minusMinutes(1), + gradeSeatMap); // Act var response = testUtils.post("/api/show/schedule", request) @@ -161,8 +168,9 @@ public class POST_specs { 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()); - var request = generateShowScheduleRegisterRequest(show, + 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), @@ -187,12 +195,13 @@ public class POST_specs { 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()); - var request = new ShowScheduleRegisterRequest( + 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), - getSeatUsageRequest(sectionId, gradeSeatMap) + gradeSeatMap ); // Act @@ -214,7 +223,7 @@ public class POST_specs { 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()); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); var request = new ShowScheduleRegisterRequest( requireNonNull(show.getId()), LocalDateTime.of(2023, 9, 10, 19, 0), @@ -244,16 +253,16 @@ public class POST_specs { ); var hallId = show.getHallId(); var sectionId = testFixture.findSectionIdsByHallId(hallId).stream().findFirst().get(); - var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId()); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); var request = generateShowScheduleRegisterRequest( - show, + show.getId(), sectionId, LocalDateTime.now(), LocalDateTime.now().plusHours(2), gradeSeatMap); var nextRequest = generateShowScheduleRegisterRequest( - show, + show.getId(), sectionId, LocalDateTime.now().plusHours(1), LocalDateTime.now().plusHours(3), @@ -286,7 +295,9 @@ public class POST_specs { ) { // Arrange var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); - var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId()); + + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), + testFixture.findSectionIdsByHallId(show.getHallId()).stream().findFirst().get()); var sectionId = 9999L; var request = new ShowScheduleRegisterRequest( show.getId(), @@ -295,9 +306,7 @@ public class POST_specs { new SeatUsageRequest( sectionId,// 존재하지 않는 sectionId List.of(), - gradeSeatMap.entrySet().stream() - .map(entry -> new GradeAssignmentRequest(entry.getKey(), entry.getValue())) - .toList() + getGradeAssignments(gradeSeatMap) ) ); @@ -318,6 +327,7 @@ public class POST_specs { // 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; var request = new ShowScheduleRegisterRequest( show.getId(), @@ -326,7 +336,7 @@ public class POST_specs { new SeatUsageRequest( sectionId, List.of(invalidSeatId), - List.of(new GradeAssignmentRequest(1L, List.of())) + getGradeAssignments(gradeSeatMap) ) ); @@ -406,6 +416,10 @@ public class POST_specs { 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), @@ -413,7 +427,8 @@ public class POST_specs { new SeatUsageRequest( sectionId, List.of(), - List.of(new GradeAssignmentRequest(1L, seatIds), new GradeAssignmentRequest(1L, seatIds)) + List.of(new GradeAssignmentRequest(1L, firstHalf), + new GradeAssignmentRequest(1L, secondHalf)) ) ); @@ -424,6 +439,7 @@ public class POST_specs { // Assert assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + System.out.println("request = " + request); assertThat(response.getData()).isEqualTo("gradeAssignments gradeIds must not contain duplicates"); } @@ -435,7 +451,7 @@ public class POST_specs { // 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 seatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId()); + var seatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); var invalidSeatId = 9999L; seatMap.entrySet().stream().findFirst().ifPresent(entry -> entry.getValue().add(invalidSeatId)); @@ -446,9 +462,7 @@ public class POST_specs { new SeatUsageRequest( sectionId, List.of(), - seatMap.entrySet().stream() - .map(entry -> new GradeAssignmentRequest(entry.getKey(), entry.getValue())) - .toList() + getGradeAssignments(seatMap) ) ); @@ -469,7 +483,7 @@ public class POST_specs { // 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 seatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId()); + var seatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); seatMap.entrySet().stream() .findFirst() .ifPresent(entry -> { @@ -483,9 +497,7 @@ public class POST_specs { new SeatUsageRequest( sectionId, List.of(), - seatMap.entrySet().stream() - .map(entry -> new GradeAssignmentRequest(entry.getKey(), entry.getValue())) - .toList() + getGradeAssignments(seatMap) ) ); @@ -500,4 +512,10 @@ public class POST_specs { assertThat(response.getData()).isEqualTo( "gradeAssignments seatIds must not contain duplicates across all assignments"); } + + private static List getGradeAssignments(Map> gradeSeatMap) { + return gradeSeatMap.entrySet().stream() + .map(entry -> new GradeAssignmentRequest(entry.getKey(), entry.getValue())) + .toList(); + } } 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 5477b85..bfe4600 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 @@ -62,6 +62,12 @@ public boolean hasUniqueSeatIdsInGradeAssignments() { .flatMap(assignment -> assignment.seatIds().stream()) .allMatch(allSeatIds::add); } + + public List includeSeatIds() { + return gradeAssignments.stream() + .flatMap(assignment -> assignment.seatIds().stream()) + .toList(); + } } public record GradeAssignmentRequest( From d95643b5e39746ca6ebebaa5c8aac0816760d376 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 24 Oct 2025 23:47:34 +0900 Subject: [PATCH 17/38] refactor show schedule registration tests to use generateShowScheduleRegisterRequest and simplify seat mapping --- .../webapi/show/schedule/POST_specs.java | 58 +++++++------------ 1 file changed, 21 insertions(+), 37 deletions(-) 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 ce7501a..68f80a3 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 @@ -16,7 +16,6 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; -import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; @@ -298,16 +297,13 @@ public class POST_specs { var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), testFixture.findSectionIdsByHallId(show.getHallId()).stream().findFirst().get()); - var sectionId = 9999L; - var request = new ShowScheduleRegisterRequest( + var sectionId = 9999L;// 존재하지 않는 sectionId + var request = generateShowScheduleRegisterRequest( show.getId(), + sectionId, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30), - new SeatUsageRequest( - sectionId,// 존재하지 않는 sectionId - List.of(), - getGradeAssignments(gradeSeatMap) - ) + gradeSeatMap ); // Act @@ -329,15 +325,14 @@ public class POST_specs { long sectionId = testFixture.findSectionIdsByHallId(show.getHallId()).stream().findFirst().get(); var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); var invalidSeatId = 9999L; - var request = new ShowScheduleRegisterRequest( + 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), - new SeatUsageRequest( - sectionId, - List.of(invalidSeatId), - getGradeAssignments(gradeSeatMap) - ) + gradeSeatMap ); // Act @@ -451,19 +446,18 @@ public class POST_specs { // 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 seatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); var invalidSeatId = 9999L; - seatMap.entrySet().stream().findFirst().ifPresent(entry -> entry.getValue().add(invalidSeatId)); + gradeSeatMap.entrySet().stream() + .findFirst() + .ifPresent(entry -> entry.getValue().add(invalidSeatId)); - var request = new ShowScheduleRegisterRequest( + var request = generateShowScheduleRegisterRequest( show.getId(), + sectionId, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30), - new SeatUsageRequest( - sectionId, - List.of(), - getGradeAssignments(seatMap) - ) + gradeSeatMap ); // Act @@ -483,23 +477,19 @@ public class POST_specs { // 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 seatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); - seatMap.entrySet().stream() + var gradeSeatMap = testFixture.generateGradeSeatMapByShowIdAndSectionId(show.getId(), sectionId); + gradeSeatMap.entrySet().stream() .findFirst() .ifPresent(entry -> { var firstSeatId = entry.getValue().getFirst(); entry.getValue().add(firstSeatId); }); - var request = new ShowScheduleRegisterRequest( + var request = generateShowScheduleRegisterRequest( show.getId(), + sectionId, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30), - new SeatUsageRequest( - sectionId, - List.of(), - getGradeAssignments(seatMap) - ) - + gradeSeatMap ); // Act @@ -512,10 +502,4 @@ public class POST_specs { assertThat(response.getData()).isEqualTo( "gradeAssignments seatIds must not contain duplicates across all assignments"); } - - private static List getGradeAssignments(Map> gradeSeatMap) { - return gradeSeatMap.entrySet().stream() - .map(entry -> new GradeAssignmentRequest(entry.getKey(), entry.getValue())) - .toList(); - } } From 7342a6cc3670ba53060c4f74cca010b824b68e7a Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 25 Oct 2025 00:24:58 +0900 Subject: [PATCH 18/38] add validation to ensure all seat IDs match section seats in show schedule registration --- .../booking/app/hall/HallQueryRepository.java | 13 +++++++ .../booking/app/hall/HallService.java | 9 ++++- .../booking/app/hall/HallValidator.java | 2 ++ .../booking/app/show/ShowService.java | 1 + .../webapi/show/schedule/POST_specs.java | 35 +++++++++++++++++++ docs/specs/api/show_schedule_register.md | 2 +- .../show/ShowScheduleRegisterRequest.java | 8 +++++ 7 files changed, 68 insertions(+), 2 deletions(-) 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 a0e5dd8..0204072 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 @@ -48,4 +48,17 @@ boolean containsSeatIdsBySectionId(Long sectionId, List seatIds) { .fetch(); return new HashSet<>(fetched).containsAll(seatIds); } + + boolean equalsSeatIdsBySectionId(Long sectionId, List seatIds) { + if (seatIds.isEmpty()) { + return true; + } + 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 c0c4ce7..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 @@ -43,7 +43,14 @@ public void checkHallExistBySectionId(Long hallId, Long sectionId) { @Override public void checkHallInvalidSeatIds(Long sectionId, List seatIds) { if (!queryRepository.containsSeatIdsBySectionId(sectionId, seatIds)) { - throw new HallException("BAD_REQUEST", "해당 섹션에 존재하지 않는 좌석이 있습니다."); + throw new HallException("BAD_REQUEST", "해당 섹션에 해당하지 않는 좌석이 있습니다."); + } + } + + @Override + public void checkSectionContainsAllOf(Long sectionId, List seatIds) { + if (!queryRepository.equalsSeatIdsBySectionId(sectionId, seatIds)) { + throw new HallException("BAD_REQUEST", "해당 섹션 좌석과 총 좌석이 상이합니다."); } } 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 54d91de..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 @@ -10,4 +10,6 @@ public interface HallValidator { 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/ShowService.java b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java index 3508a82..bd3ae72 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 @@ -54,6 +54,7 @@ public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest hallValidator.checkHallExistBySectionId(show.getHallId(), sectionId); hallValidator.checkHallInvalidSeatIds(sectionId, excludeSeatIds); hallValidator.checkHallInvalidSeatIds(sectionId, includeSeatIds); + hallValidator.checkSectionContainsAllOf(sectionId, request.use().allSeatIds()); show.validateGradeIds(request.use().gradeAssignments().stream().map(GradeAssignmentRequest::gradeId).toList()); checkConflictSchedule(show.getHallId(), request); 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 68f80a3..f16cb5d 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 @@ -502,4 +502,39 @@ public class POST_specs { 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(), + 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("해당 섹션 좌석과 총 좌석이 상이합니다."); + } } diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 681bf37..06c5af2 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -81,7 +81,7 @@ - [x] gradeAssignments의 gradeId가 중복된 경우 BAD_REQUEST를 반환한다 - [x] gradeAssignments의 seatIds에 해당 hall의 seat id가 존재하지 않는 경우 BAD_REQUEST 반환한다 - [x] gradeAssignments의 seatIds에 중복된 좌석이 존재하는 경우 BAD_REQUEST를 반환한다 -- [ ] 제외 좌석과 등록 좌석 전체가 section의 모든 좌석과 다른 경우 BAD_REQUEST를 반환한다 +- [x] 제외 좌석과 등록 좌석 전체가 section의 모든 좌석과 다른 경우 BAD_REQUEST를 반환한다 - [ ] 일정이 정상적으로 등록된 경우 inventory에 해당 회차의 좌석이 모두 생성된다 비고 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 bfe4600..8910333 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 @@ -5,6 +5,7 @@ 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.Set; @@ -68,6 +69,13 @@ public List includeSeatIds() { .flatMap(assignment -> assignment.seatIds().stream()) .toList(); } + + public List allSeatIds() { + List ids = new ArrayList<>(); + ids.addAll(excludeSeatIds); + ids.addAll(includeSeatIds()); + return ids; + } } public record GradeAssignmentRequest( From 3cba248f161fd6d02111264eecbc3a9f139701ab Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 6 Nov 2025 09:39:10 +0900 Subject: [PATCH 19/38] add validation to return BAD_REQUEST for duplicate seat IDs in gradeAssignments --- .../app/show/InventoryCommandRepository.java | 15 +++++++ .../booking/app/show/InventoryRepository.java | 8 ++++ .../booking/app/show/InventoryService.java | 19 ++++++++ .../booking/app/show/InventoryWriter.java | 8 ++++ .../app/show/ShowCommandRepository.java | 8 +++- .../booking/app/show/ShowService.java | 27 ++++++++---- .../mandarin/booking/utils/TestFixture.java | 9 ++++ .../webapi/show/schedule/POST_specs.java | 37 ++++++++++++++++ .../mandarin/booking/domain/hall/Hall.java | 9 ++++ .../mandarin/booking/domain/hall/Seat.java | 2 +- .../mandarin/booking/domain/hall/Section.java | 4 +- .../mandarin/booking/domain/show/Grade.java | 2 +- .../booking/domain/show/Inventory.java | 43 +++++++++++++++++++ .../booking/domain/show/SeatState.java | 36 ++++++++++++++++ .../mandarin/booking/domain/show/Show.java | 15 +++++-- .../booking/domain/show/ShowSchedule.java | 2 +- .../show/ShowScheduleRegisterRequest.java | 11 +++++ 17 files changed, 237 insertions(+), 18 deletions(-) create mode 100644 application/src/main/java/org/mandarin/booking/app/show/InventoryCommandRepository.java create mode 100644 application/src/main/java/org/mandarin/booking/app/show/InventoryRepository.java create mode 100644 application/src/main/java/org/mandarin/booking/app/show/InventoryService.java create mode 100644 application/src/main/java/org/mandarin/booking/app/show/InventoryWriter.java create mode 100644 domain/src/main/java/org/mandarin/booking/domain/show/Inventory.java create mode 100644 domain/src/main/java/org/mandarin/booking/domain/show/SeatState.java 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..8a608e0 --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/show/InventoryCommandRepository.java @@ -0,0 +1,15 @@ +package org.mandarin.booking.app.show; + +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.domain.show.Inventory; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +class InventoryCommandRepository { + private final InventoryRepository repository; + + void insert(Inventory inventory) { + repository.save(inventory); + } +} 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 bd3ae72..31c8b52 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 @@ -30,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) { @@ -48,24 +49,24 @@ public ShowRegisterResponse register(ShowRegisterRequest request) { @Override public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest request) { var show = queryRepository.findById(request.showId()); - var sectionId = request.use().sectionId(); - var excludeSeatIds = request.use().excludeSeatIds(); - var includeSeatIds = request.use().includeSeatIds(); - hallValidator.checkHallExistBySectionId(show.getHallId(), sectionId); - hallValidator.checkHallInvalidSeatIds(sectionId, excludeSeatIds); - hallValidator.checkHallInvalidSeatIds(sectionId, includeSeatIds); - hallValidator.checkSectionContainsAllOf(sectionId, request.use().allSeatIds()); + 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); + + var hall = hallFetcher.fetch(show.getHallId()); + + var seatsByGradeIds = request.use().seatsByGradeId(saved, hall); + + inventoryWriter.createInventory(schedule.getId(), seatsByGradeIds); + return new ShowScheduleRegisterResponse(requireNonNull(saved.getId())); } - @Override public SliceView fetchShows(Integer page, Integer size, String type, String rating, String q, LocalDate from, LocalDate to) { @@ -105,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/test/java/org/mandarin/booking/utils/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java index 161211a..e7ca73b 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -25,6 +25,7 @@ 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; @@ -299,6 +300,14 @@ public Map> generateGradeSeatMapByShowIdAndSectionId(Long showI 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 Map> gerateGradeSeatMap(List gradeIds, List seatIds) { Map> result = new HashMap<>(); var gradeCount = gradeIds.size(); 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 f16cb5d..7a39a12 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; @@ -26,6 +27,7 @@ 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") @@ -537,4 +539,39 @@ public class POST_specs { 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); + } + ); + } } 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 608e929..04b8efb 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 @@ -32,6 +32,15 @@ public Hall(String hallName, String registantId) { this.registantId = 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 static Hall create(String name, @Size @Valid List sections, String registantId) { var hall = new Hall(name, registantId); sections.forEach(req 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 d18a203..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 @@ -18,7 +18,7 @@ @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; 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 65eb66d..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,12 +22,13 @@ @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; 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..45b6219 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) 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..70c7d1c --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/show/Inventory.java @@ -0,0 +1,43 @@ +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; + + +@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 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..0e4e801 --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/show/SeatState.java @@ -0,0 +1,36 @@ +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; + } +} 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 4a54d28..5c3b773 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 @@ -32,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; @@ -53,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, @@ -98,13 +99,14 @@ public static Show create(Long hallId, ShowCreateCommand command) { 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() { @@ -137,6 +139,13 @@ public void validateGradeIds(List gradeIds) { } } + public Grade getGradeById(Long gradeId) { + return this.grades.stream() + .filter(grade -> grade.getId().equals(gradeId)) + .findFirst() + .orElseThrow(() -> new ShowException("존재하지 않는 등급입니다.")); + } + private void addGrades(List grades) { this.grades.addAll(grades); } 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 8910333..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 @@ -8,7 +8,10 @@ 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") @@ -76,6 +79,14 @@ public List allSeatIds() { 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( From 3f73279e6620f1ea43ec343bbe66d20200dfd10e Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 6 Nov 2025 10:09:56 +0900 Subject: [PATCH 20/38] return schedule Id for response --- .../mandarin/booking/app/show/InventoryCommandRepository.java | 2 ++ .../main/java/org/mandarin/booking/app/show/ShowService.java | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) 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 index 8a608e0..6a25567 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/InventoryCommandRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/InventoryCommandRepository.java @@ -3,8 +3,10 @@ import lombok.RequiredArgsConstructor; import org.mandarin.booking.domain.show.Inventory; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; @Repository +@Transactional @RequiredArgsConstructor class InventoryCommandRepository { private final InventoryRepository repository; 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 31c8b52..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 @@ -64,7 +64,7 @@ public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest inventoryWriter.createInventory(schedule.getId(), seatsByGradeIds); - return new ShowScheduleRegisterResponse(requireNonNull(saved.getId())); + return new ShowScheduleRegisterResponse(requireNonNull(schedule.getId())); } @Override From 3612387faa315c469cc8e06f5e296c1aa427b3d4 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 6 Nov 2025 10:20:15 +0900 Subject: [PATCH 21/38] mark schedule registration as complete when inventory seats are created --- docs/specs/api/show_schedule_register.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 06c5af2..69154f6 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -82,7 +82,7 @@ - [x] gradeAssignments의 seatIds에 해당 hall의 seat id가 존재하지 않는 경우 BAD_REQUEST 반환한다 - [x] gradeAssignments의 seatIds에 중복된 좌석이 존재하는 경우 BAD_REQUEST를 반환한다 - [x] 제외 좌석과 등록 좌석 전체가 section의 모든 좌석과 다른 경우 BAD_REQUEST를 반환한다 -- [ ] 일정이 정상적으로 등록된 경우 inventory에 해당 회차의 좌석이 모두 생성된다 +- [x] 일정이 정상적으로 등록된 경우 inventory에 해당 회차의 좌석이 모두 생성된다 비고 From 1da01481e87ff818434eb02952debd54a17a3f44 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 6 Nov 2025 10:37:43 +0900 Subject: [PATCH 22/38] remove empty seat ID checks in section ID validation methods --- .../org/mandarin/booking/app/hall/HallQueryRepository.java | 6 ------ .../mandarin/booking/webapi/show/schedule/POST_specs.java | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) 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 0204072..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 @@ -37,9 +37,6 @@ boolean existsByHallIdAndSectionId(Long hallId, Long sectionId) { } boolean containsSeatIdsBySectionId(Long sectionId, List seatIds) { - if (seatIds.isEmpty()) { - return true; - } var fetched = jpaQueryFactory .select(seat.id) .from(section) @@ -50,9 +47,6 @@ boolean containsSeatIdsBySectionId(Long sectionId, List seatIds) { } boolean equalsSeatIdsBySectionId(Long sectionId, List seatIds) { - if (seatIds.isEmpty()) { - return true; - } var fetched = jpaQueryFactory .select(seat.id) .from(section) 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 7a39a12..c987c82 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 @@ -372,6 +372,7 @@ public class POST_specs { // Assert assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + assertThat(response.getData()).isEqualTo("excludeSeatIds must not contain duplicates"); } @Test @@ -436,7 +437,6 @@ public class POST_specs { // Assert assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); - System.out.println("request = " + request); assertThat(response.getData()).isEqualTo("gradeAssignments gradeIds must not contain duplicates"); } From c7d85728b598f0018d3db02658fba43fd414b28e Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 6 Nov 2025 10:50:22 +0900 Subject: [PATCH 23/38] add test to validate successful response when excludeSeatIds have no duplicates --- .../webapi/show/schedule/POST_specs.java | 41 ++++++++++++++++++- docs/specs/api/show_schedule_register.md | 1 + 2 files changed, 41 insertions(+), 1 deletion(-) 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 c987c82..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 @@ -361,7 +361,7 @@ public class POST_specs { new SeatUsageRequest( sectionId, List.of(1L, 1L), - List.of(new GradeAssignmentRequest(1L, List.of())) + List.of(new GradeAssignmentRequest(1L, List.of(1L, 2L))) ) ); @@ -574,4 +574,43 @@ public class POST_specs { } ); } + + @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/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 69154f6..c33a13f 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -83,6 +83,7 @@ - [x] gradeAssignments의 seatIds에 중복된 좌석이 존재하는 경우 BAD_REQUEST를 반환한다 - [x] 제외 좌석과 등록 좌석 전체가 section의 모든 좌석과 다른 경우 BAD_REQUEST를 반환한다 - [x] 일정이 정상적으로 등록된 경우 inventory에 해당 회차의 좌석이 모두 생성된다 +- [x] excludeSeatIds에_중복이_없으면_TRUE_로_검증되고_SUCCESS를_반환한다 비고 From a9d6e8b054e615e928d63294919bf23db8beda8a Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 6 Nov 2025 10:51:40 +0900 Subject: [PATCH 24/38] simplify toSnakeCase method for better readability and performance --- .../app/TableAwarePhysicalNamingStrategy.java | 24 ++++--------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/application/src/main/java/org/mandarin/booking/app/TableAwarePhysicalNamingStrategy.java b/application/src/main/java/org/mandarin/booking/app/TableAwarePhysicalNamingStrategy.java index a1995cf..c6f3157 100644 --- a/application/src/main/java/org/mandarin/booking/app/TableAwarePhysicalNamingStrategy.java +++ b/application/src/main/java/org/mandarin/booking/app/TableAwarePhysicalNamingStrategy.java @@ -19,28 +19,14 @@ public Identifier toPhysicalTableName(Identifier name, JdbcEnvironment jdbcEnvir @Override public Identifier toPhysicalColumnName(Identifier name, JdbcEnvironment jdbcEnvironment) { var logical = name.getText(); - var table = CURRENT_TABLE.get(); - var physical = "id".equalsIgnoreCase(logical) - ? (table == null || table.isBlank() ? "id" : table + "_id") - : toSnakeCase(logical); + var physical = toSnakeCase(logical); return Identifier.toIdentifier(physical, name.isQuoted()); } private static String toSnakeCase(String s) { - if (s.isEmpty()) { - return s; - } - var n = s.length(); - var sb = new StringBuilder(n + 8); - for (int i = 0; i < n; i++) { - var c = s.charAt(i); - if (Character.isUpperCase(c) - && i > 0 - && (Character.isLowerCase(s.charAt(i - 1)) || (i + 1 < n && Character.isLowerCase(s.charAt(i + 1))))) { - sb.append('_'); - } - sb.append(Character.toLowerCase(c)); - } - return sb.toString(); + 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(); } } From b07803769003b07ae2907a99ebee6f6d433f1d38 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Thu, 6 Nov 2025 12:29:55 +0900 Subject: [PATCH 25/38] add role hierarchy configuration for security authorization --- ...licationAuthorizationRequestMatcherConfigurer.java | 11 +++++++++++ .../org/mandarin/booking/adapter/SecurityConfig.java | 8 -------- 2 files changed, 11 insertions(+), 8 deletions(-) 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/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) { From a3004564f2e5c43038038bb4ab2e439134be5354 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 7 Nov 2025 12:37:56 +0900 Subject: [PATCH 26/38] add JdbcBatchUtils for batch processing with customizable parameter binding --- .../mandarin/booking/app/JdbcBatchUtils.java | 51 ++++++++++ .../app/show/SeatStateBatchRepository.java | 27 +++++ .../booking/app/JdbcBatchUtilsTest.java | 99 +++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 application/src/main/java/org/mandarin/booking/app/JdbcBatchUtils.java create mode 100644 application/src/main/java/org/mandarin/booking/app/show/SeatStateBatchRepository.java create mode 100644 application/src/test/java/org/mandarin/booking/app/JdbcBatchUtilsTest.java 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/show/SeatStateBatchRepository.java b/application/src/main/java/org/mandarin/booking/app/show/SeatStateBatchRepository.java new file mode 100644 index 0000000..f3a7bee --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/show/SeatStateBatchRepository.java @@ -0,0 +1,27 @@ +package org.mandarin.booking.app.show; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.JdbcBatchUtils; +import org.mandarin.booking.domain.show.SeatState.SeatStateRow; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +class SeatStateBatchRepository { + private final JdbcBatchUtils jdbcBatchUtils; + + void batchInsert(Long inventoryId, List rows) { + String sql = "INSERT INTO seat_state (inventory_id, seat_id, grade_id) VALUES (?, ?, ?)"; + jdbcBatchUtils.batchUpdate( + sql, + rows, + (ps, row) -> { + ps.setLong(1, inventoryId); + ps.setLong(2, row.seatId()); + ps.setLong(3, row.gradeId()); + }, + 1000 + ); + } +} 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); + } +} + From 2750ef75939c306e9a3ed3ba61d4435c7412a4e6 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 7 Nov 2025 12:38:18 +0900 Subject: [PATCH 27/38] add methods to extract and clear seat states in Inventory and integrate batch insert in InventoryCommandRepository --- .../booking/app/show/InventoryCommandRepository.java | 8 ++++++++ .../org/mandarin/booking/domain/show/Inventory.java | 11 +++++++++++ .../org/mandarin/booking/domain/show/SeatState.java | 7 +++++++ 3 files changed, 26 insertions(+) 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 index 6a25567..b90e638 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/InventoryCommandRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/InventoryCommandRepository.java @@ -1,7 +1,9 @@ package org.mandarin.booking.app.show; +import java.util.List; import lombok.RequiredArgsConstructor; 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; @@ -10,8 +12,14 @@ @RequiredArgsConstructor class InventoryCommandRepository { private final InventoryRepository repository; + private final SeatStateBatchRepository seatStateBatchRepository; void insert(Inventory inventory) { + List rows = inventory.extractSeatStateRows(); + inventory.clearSeatStates();// jpa가 아닌 jdbc로 일괄 삽입하기 위해 clear + repository.save(inventory); + + seatStateBatchRepository.batchInsert(inventory.getId(), rows); } } 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 index 70c7d1c..e23d6b6 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/Inventory.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/Inventory.java @@ -14,6 +14,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import org.mandarin.booking.domain.AbstractEntity; +import org.mandarin.booking.domain.show.SeatState.SeatStateRow; @Entity @@ -40,4 +41,14 @@ public static Inventory create(Long showScheduleId, Map> seatAs inventory.states.addAll(seatStates); return inventory; } + + public List extractSeatStateRows() { + return states.stream() + .map(SeatState::extractRow) + .toList(); + } + + public void clearSeatStates() { + this.states.clear(); + } } 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 index 0e4e801..f32297c 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/SeatState.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/SeatState.java @@ -33,4 +33,11 @@ static SeatState create(Inventory inventory, Long seatId, Long gradeId) { state.gradeId = gradeId; return state; } + + SeatStateRow extractRow() { + return new SeatStateRow(seatId, gradeId); + } + + public record SeatStateRow(Long seatId, Long gradeId) { + } } From fb1e1762bc4630393386085c62130824b7406b74 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 7 Nov 2025 12:38:30 +0900 Subject: [PATCH 28/38] refactor TestFixture to integrate JdbcBatchUtils for batch processing of shows and sections --- .../mandarin/booking/utils/TestConfig.java | 6 +- .../mandarin/booking/utils/TestFixture.java | 210 +++++++++++++----- 2 files changed, 159 insertions(+), 57 deletions(-) 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 e7ca73b..bd02c39 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -8,7 +8,6 @@ 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.generateGradeRequest; import static org.mandarin.booking.utils.ShowFixture.generateShowScheduleCreateCommand; import jakarta.persistence.EntityManager; @@ -20,6 +19,7 @@ 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; @@ -40,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() { @@ -113,9 +116,28 @@ public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanc public Hall insertDummyHall(String userId) { List sections = generateSectionRegisterRequest(10, 100); - var hall = Hall.create(generateHallName(), sections, userId); - entityManager.persist(hall); - return hall; + 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) { @@ -137,54 +159,110 @@ 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", - generateGradeRequest(5) - ); - 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); + } + + private Hall insertHallGraph(String hallName, String userId, List sections) { + var hall = new Hall(hallName, userId); + entityManager.persist(hall); + entityManager.flush(); + long hallId = hall.getId(); + + 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; } public Show generateShow(List grades) { @@ -288,15 +366,12 @@ public List findSeatIdsBySectionId(long sectionId) { } public Map> generateGradeSeatMapByShowIdAndSectionId(Long showId, Long sectionId) { - // grade id 가져오기 var gradeIds = findGradeIdsByShowId(showId); - // seat id 가져오기 var seatIds = entityManager.createQuery("SELECT seat.id FROM Seat seat WHERE seat.section.id = :sectionId", Long.class) .setParameter("sectionId", sectionId) .getResultList(); - // seatIds를 gradeIds의 개수만큼 분할하여 매핑 return gerateGradeSeatMap(gradeIds, seatIds); } @@ -335,12 +410,6 @@ private Show generateShow(Long hallId) { 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, @@ -361,12 +430,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; @@ -382,4 +445,41 @@ private List findGradeIdsByShowId(Long showId) { .setParameter("showId", showId) .getResultList(); } + + private void batchInsertShows(List rows) { + if (rows == null || rows.isEmpty()) { + return; + } + String sql = + "INSERT INTO shows (hall_id, title, type, rating, synopsis, poster_url, performance_start_date, performance_end_date, currency) " + + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; + jdbcBatchUtils.batchUpdate( + sql, + 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 + ); + } + + private record ShowRow(Long hallId, + String title, + String type, + String rating, + String synopsis, + String posterUrl, + LocalDate performanceStartDate, + LocalDate performanceEndDate, + String currency) { + } } From 4fc70244480f5cf4d813c66279f63acad9bab6a4 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 7 Nov 2025 12:58:29 +0900 Subject: [PATCH 29/38] add methods to extract seat rows and clear seats in Hall, implement batch insert in HallCommandRepository --- .../app/hall/HallCommandRepository.java | 29 ++++++++++++++++++- .../mandarin/booking/domain/hall/Hall.java | 19 ++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) 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..4ef2fc2 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 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(List rows) { + String sql = "INSERT INTO seat (section_id, seat_row, seat_number) VALUES (?, ?, ?)"; + jdbcBatchUtils.batchUpdate( + sql, + rows, + (ps, row) -> { + ps.setLong(1, row.section().getId()); + ps.setString(2, row.rowNumber()); + ps.setString(3, row.seatNumber()); + }, + 1000 + ); } } 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 04b8efb..d3389ca 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 @@ -51,4 +51,23 @@ public static Hall create(String name, @Size @Valid List 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 record SeatInsertRow(Section section, String rowNumber, String seatNumber) { + } } From 953bcd344ceb2cab08975479d93b6de6cc3945f5 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 7 Nov 2025 12:58:36 +0900 Subject: [PATCH 30/38] refactor InventoryCommandRepository to use JdbcBatchUtils for batch insertion of seat states --- .../app/show/InventoryCommandRepository.java | 19 +++++++++++-- .../app/show/SeatStateBatchRepository.java | 27 ------------------- 2 files changed, 17 insertions(+), 29 deletions(-) delete mode 100644 application/src/main/java/org/mandarin/booking/app/show/SeatStateBatchRepository.java 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 index b90e638..0362ae6 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/InventoryCommandRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/InventoryCommandRepository.java @@ -2,6 +2,7 @@ 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; @@ -12,7 +13,7 @@ @RequiredArgsConstructor class InventoryCommandRepository { private final InventoryRepository repository; - private final SeatStateBatchRepository seatStateBatchRepository; + private final JdbcBatchUtils jdbcBatchUtils; void insert(Inventory inventory) { List rows = inventory.extractSeatStateRows(); @@ -20,6 +21,20 @@ void insert(Inventory inventory) { repository.save(inventory); - seatStateBatchRepository.batchInsert(inventory.getId(), rows); + batchInsert(inventory.getId(), rows); + } + + void batchInsert(Long inventoryId, List rows) { + String sql = "INSERT INTO seat_state (inventory_id, seat_id, grade_id) VALUES (?, ?, ?)"; + jdbcBatchUtils.batchUpdate( + sql, + 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/SeatStateBatchRepository.java b/application/src/main/java/org/mandarin/booking/app/show/SeatStateBatchRepository.java deleted file mode 100644 index f3a7bee..0000000 --- a/application/src/main/java/org/mandarin/booking/app/show/SeatStateBatchRepository.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.mandarin.booking.app.show; - -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.mandarin.booking.app.JdbcBatchUtils; -import org.mandarin.booking.domain.show.SeatState.SeatStateRow; -import org.springframework.stereotype.Repository; - -@Repository -@RequiredArgsConstructor -class SeatStateBatchRepository { - private final JdbcBatchUtils jdbcBatchUtils; - - void batchInsert(Long inventoryId, List rows) { - String sql = "INSERT INTO seat_state (inventory_id, seat_id, grade_id) VALUES (?, ?, ?)"; - jdbcBatchUtils.batchUpdate( - sql, - rows, - (ps, row) -> { - ps.setLong(1, inventoryId); - ps.setLong(2, row.seatId()); - ps.setLong(3, row.gradeId()); - }, - 1000 - ); - } -} From d150de7b89c67edcdd12ae8f1349e78e1330c2de Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 7 Nov 2025 15:35:38 +0900 Subject: [PATCH 31/38] add StringFormatterUtils for converting strings to snake_case and refactor TableAwarePhysicalNamingStrategy to use it --- .../app/TableAwarePhysicalNamingStrategy.java | 10 +++------- .../org/mandarin/booking/StringFormatterUtils.java | 12 ++++++++++++ 2 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 common/src/main/java/org/mandarin/booking/StringFormatterUtils.java diff --git a/application/src/main/java/org/mandarin/booking/app/TableAwarePhysicalNamingStrategy.java b/application/src/main/java/org/mandarin/booking/app/TableAwarePhysicalNamingStrategy.java index c6f3157..494d0f9 100644 --- a/application/src/main/java/org/mandarin/booking/app/TableAwarePhysicalNamingStrategy.java +++ b/application/src/main/java/org/mandarin/booking/app/TableAwarePhysicalNamingStrategy.java @@ -1,5 +1,8 @@ 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; @@ -22,11 +25,4 @@ public Identifier toPhysicalColumnName(Identifier name, JdbcEnvironment jdbcEnvi var physical = toSnakeCase(logical); return Identifier.toIdentifier(physical, name.isQuoted()); } - - private 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/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(); + } +} From bf02544f1f17513b61fd2510986d1f7bf8c62c55 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 7 Nov 2025 15:35:53 +0900 Subject: [PATCH 32/38] add EntityInsertBuilder for dynamic SQL generation and refactor batch insert methods in HallCommandRepository and InventoryCommandRepository --- .../app/hall/HallCommandRepository.java | 17 +- .../app/show/InventoryCommandRepository.java | 18 +- .../booking/domain/EntityInsertBuilder.java | 219 ++++++++++++++++++ 3 files changed, 232 insertions(+), 22 deletions(-) create mode 100644 domain/src/main/java/org/mandarin/booking/domain/EntityInsertBuilder.java 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 4ef2fc2..48f3165 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 @@ -3,8 +3,10 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.mandarin.booking.app.JdbcBatchUtils; +import org.mandarin.booking.domain.EntityInsertBuilder; import org.mandarin.booking.domain.hall.Hall; import org.mandarin.booking.domain.hall.Hall.SeatInsertRow; +import org.mandarin.booking.domain.hall.Seat; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -27,16 +29,9 @@ Hall insert(Hall hall) { } private void batchInsertSeats(List rows) { - String sql = "INSERT INTO seat (section_id, seat_row, seat_number) VALUES (?, ?, ?)"; - jdbcBatchUtils.batchUpdate( - sql, - rows, - (ps, row) -> { - ps.setLong(1, row.section().getId()); - ps.setString(2, row.rowNumber()); - ps.setString(3, row.seatNumber()); - }, - 1000 - ); + var compiled = EntityInsertBuilder.forTable("seat", SeatInsertRow.class, Seat.class) + .autoBindAll() + .compile(); + jdbcBatchUtils.batchUpdate(compiled.sql(), rows, (ps, row) -> compiled.binder().bind(ps, row), 1000); } } 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 index 0362ae6..7ea8cdf 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/InventoryCommandRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/InventoryCommandRepository.java @@ -3,7 +3,9 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.mandarin.booking.app.JdbcBatchUtils; +import org.mandarin.booking.domain.EntityInsertBuilder; import org.mandarin.booking.domain.show.Inventory; +import org.mandarin.booking.domain.show.SeatState; import org.mandarin.booking.domain.show.SeatState.SeatStateRow; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -25,16 +27,10 @@ void insert(Inventory inventory) { } void batchInsert(Long inventoryId, List rows) { - String sql = "INSERT INTO seat_state (inventory_id, seat_id, grade_id) VALUES (?, ?, ?)"; - jdbcBatchUtils.batchUpdate( - sql, - rows, - (ps, row) -> { - ps.setLong(1, inventoryId); - ps.setLong(2, row.seatId()); - ps.setLong(3, row.gradeId()); - }, - 1000 - ); + var compiled = EntityInsertBuilder.forTable("seat_state", SeatStateRow.class, SeatState.class) + .withForeignKey(inventoryId) + .autoBindAll() + .compile(); + jdbcBatchUtils.batchUpdate(compiled.sql(), rows, (ps, row) -> compiled.binder().bind(ps, row), 1000); } } diff --git a/domain/src/main/java/org/mandarin/booking/domain/EntityInsertBuilder.java b/domain/src/main/java/org/mandarin/booking/domain/EntityInsertBuilder.java new file mode 100644 index 0000000..8b3d42f --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/EntityInsertBuilder.java @@ -0,0 +1,219 @@ +package org.mandarin.booking.domain; + +import static org.mandarin.booking.StringFormatterUtils.toSnakeCase; + +import jakarta.persistence.Column; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import java.io.Serializable; +import java.lang.invoke.SerializedLambda; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.StringJoiner; +import java.util.function.Function; + +public final class EntityInsertBuilder { + private final String table; + private final Class recordType; + private final Class entityType; + private final List constants = new ArrayList<>(); + private final List> bindings = new ArrayList<>(); + + private EntityInsertBuilder(String table, Class recordType, Class entityType) { + this.table = table; + this.recordType = recordType; + this.entityType = entityType; + if (!recordType.isRecord()) { + throw new IllegalArgumentException("recordType must be a record"); + } + } + + public EntityInsertBuilder withForeignKey(Object value) { + String col = resolveSingleJoinColumnName(entityType); + this.constants.add(new Const(col, value)); + return this; + } + + public EntityInsertBuilder bind(Rec recordAccessor, Function mapper) { + String component = componentName(recordAccessor); + String column = resolveColumnNameByField(entityType, component); + Function extractor = r -> { + try { + @SuppressWarnings("unchecked") + V v = (V) recordType.getMethod(component).invoke(r); + return mapper.apply(v); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + }; + bindings.add(new Binding<>(column, extractor)); + return this; + } + + public EntityInsertBuilder bindAs(Rec recordAccessor, String entityFieldName) { + String component = componentName(recordAccessor); + String column = resolveColumnNameByField(entityType, entityFieldName); + + bindings.add(new Binding<>(column, r -> retrieveEntityIdOrValue(r, component))); + return this; + } + + public EntityInsertBuilder autoBindAll() { + for (var rc : recordType.getRecordComponents()) { + String name = rc.getName(); + var fOpt = findField(entityType, name); + if (fOpt.isEmpty()) { + continue; + } + var f = fOpt.get(); + if (f.getAnnotation(OneToMany.class) != null || f.getAnnotation(ManyToMany.class) != null) { + continue; + } + if (f.getAnnotation(OneToOne.class) != null && f.getAnnotation(JoinColumn.class) == null) { + continue; + } + + String column = resolveColumnNameByField(entityType, name); + bindings.add(new Binding<>(column, r -> retrieveEntityIdOrValue(r, name))); + } + return this; + } + + public Compiled compile() { + List columns = new ArrayList<>(); + constants.forEach(c -> columns.add(c.column)); + bindings.forEach(b -> columns.add(b.column)); + + StringJoiner cols = new StringJoiner(", "); + StringJoiner holders = new StringJoiner(", "); + for (String column : columns) { + cols.add(column); + holders.add("?"); + } + String sql = "INSERT INTO " + table + " (" + cols + ") VALUES (" + holders + ")"; + + Binder binder = (ps, item) -> { + int idx = 1; + for (Const c : constants) { + setObject(ps, idx++, c.value); + } + for (Binding b : bindings) { + setObject(ps, idx++, b.extractor.apply(item)); + } + }; + return new Compiled<>(sql, binder); + } + + public static EntityInsertBuilder forTable(String table, Class recordType, Class entityType) { + return new EntityInsertBuilder<>(table, recordType, entityType); + } + + private Object retrieveEntityIdOrValue(R r, String component) { + try { + Object v = recordType.getMethod(component).invoke(r); + if (v instanceof AbstractEntity ae) { + return ae.getId(); + } + return v; + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + + private static String resolveSingleJoinColumnName(Class entityType) { + Field chosen = null; + for (Field f : allFields(entityType)) { + if (f.getAnnotation(JoinColumn.class) != null) { + if (chosen != null) { + throw new IllegalArgumentException("Multiple @JoinColumn present"); + } + chosen = f; + } + } + if (chosen == null) { + throw new IllegalArgumentException("No @JoinColumn present"); + } + JoinColumn jc = chosen.getAnnotation(JoinColumn.class); + if (jc != null && !jc.name().isBlank()) { + return jc.name(); + } + return toSnakeCase(chosen.getName()) + "_id"; + } + + private static String resolveColumnNameByField(Class entityType, String fieldName) { + Field f = findField(entityType, fieldName) + .orElseThrow(() -> new IllegalArgumentException("Unknown entity field: " + fieldName)); + JoinColumn jc = f.getAnnotation(JoinColumn.class); + if (jc != null && !jc.name().isBlank()) { + return jc.name(); + } + Column c = f.getAnnotation(Column.class); + if (c != null && !c.name().isBlank()) { + return c.name(); + } + return toSnakeCase(fieldName); + } + + private static List allFields(Class type) { + List list = new ArrayList<>(); + Class t = type; + while (t != null && t != Object.class) { + list.addAll(Arrays.asList(t.getDeclaredFields())); + t = t.getSuperclass(); + } + return list; + } + + private static Optional findField(Class type, String name) { + return allFields(type).stream().filter(f -> f.getName().equals(name)).findFirst(); + } + + private static void setObject(PreparedStatement ps, int idx, Object value) throws SQLException { + switch (value) { + case String s -> ps.setString(idx, s); + case Integer i -> ps.setInt(idx, i); + case Long l -> ps.setLong(idx, l); + case Boolean b -> ps.setBoolean(idx, b); + case Double d -> ps.setDouble(idx, d); + case Float f -> ps.setFloat(idx, f); + case null, default -> ps.setObject(idx, value); + } + } + + private static String componentName(Serializable lambda) { + try { + Method m = lambda.getClass().getDeclaredMethod("writeReplace"); + m.setAccessible(true); + SerializedLambda sl = (SerializedLambda) m.invoke(lambda); + return sl.getImplMethodName(); + } catch (Exception e) { + throw new IllegalStateException("Cannot resolve component name", e); + } + } + + @FunctionalInterface + public interface Rec extends Function, Serializable { + } + + @FunctionalInterface + public interface Binder { + void bind(PreparedStatement ps, R item) throws SQLException; + } + + public record Compiled(String sql, Binder binder) { + } + + private record Const(String column, Object value) { + } + + private record Binding(String column, Function extractor) { + } +} From 9ce03581b588cbc9bc4aa5bb43923ebd9b38c772 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 7 Nov 2025 15:36:11 +0900 Subject: [PATCH 33/38] refactor TestFixture to use EntityInsertBuilder for section and shows batch insertion --- .../mandarin/booking/utils/TestFixture.java | 65 +++++++------------ 1 file changed, 25 insertions(+), 40 deletions(-) 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 bd02c39..2ec967b 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -1,6 +1,7 @@ package org.mandarin.booking.utils; import static org.mandarin.booking.MemberAuthority.ADMIN; +import static org.mandarin.booking.domain.EntityInsertBuilder.forTable; import static org.mandarin.booking.utils.EnumFixture.randomEnum; import static org.mandarin.booking.utils.HallFixture.generateHallName; import static org.mandarin.booking.utils.HallFixture.generateSectionRegisterRequest; @@ -21,6 +22,7 @@ import org.mandarin.booking.MemberAuthority; import org.mandarin.booking.app.JdbcBatchUtils; import org.mandarin.booking.domain.hall.Hall; +import org.mandarin.booking.domain.hall.Section; import org.mandarin.booking.domain.hall.SectionRegisterRequest; import org.mandarin.booking.domain.member.Member; import org.mandarin.booking.domain.member.Member.MemberCreateCommand; @@ -229,15 +231,14 @@ private Hall insertHallGraph(String hallName, String userId, List { - ps.setLong(1, hallId); - ps.setString(2, s.sectionName()); - }, - 1000 - ); + var sectionInsert = forTable( + "section", + SectionRegisterRequest.class, + Section.class) + .withForeignKey(hallId) + .bindAs(SectionRegisterRequest::sectionName, "name") + .compile(); + jdbcBatchUtils.batchUpdate(sectionInsert.sql(), sections, (ps, s) -> sectionInsert.binder().bind(ps, s), 1000); var seatParams = sections.stream() .flatMap(sec -> sec.seats().stream() @@ -450,36 +451,20 @@ private void batchInsertShows(List rows) { if (rows == null || rows.isEmpty()) { return; } - String sql = - "INSERT INTO shows (hall_id, title, type, rating, synopsis, poster_url, performance_start_date, performance_end_date, currency) " - + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; - jdbcBatchUtils.batchUpdate( - sql, - 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 - ); - } - - private record ShowRow(Long hallId, - String title, - String type, - String rating, - String synopsis, - String posterUrl, - LocalDate performanceStartDate, - LocalDate performanceEndDate, - String currency) { + var compiled = forTable("shows", ShowRow.class, Show.class) + .autoBindAll() + .compile(); + jdbcBatchUtils.batchUpdate(compiled.sql(), rows, (ps, row) -> compiled.binder().bind(ps, row), 1000); + } + + public record ShowRow(Long hallId, + String title, + String type, + String rating, + String synopsis, + String posterUrl, + LocalDate performanceStartDate, + LocalDate performanceEndDate, + String currency) { } } From fa156fce36b77a797ee79444bc4b521656fc365a Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 7 Nov 2025 23:49:15 +0900 Subject: [PATCH 34/38] refactor EntityInsertBuilder to improve binding logic and add unit tests for various scenarios --- .../booking/domain/EntityInsertBuilder.java | 167 ++++------ .../domain/EntityInsertBuilderTest.java | 294 ++++++++++++++++++ 2 files changed, 355 insertions(+), 106 deletions(-) create mode 100644 domain/src/test/java/org/mandarin/booking/domain/EntityInsertBuilderTest.java diff --git a/domain/src/main/java/org/mandarin/booking/domain/EntityInsertBuilder.java b/domain/src/main/java/org/mandarin/booking/domain/EntityInsertBuilder.java index 8b3d42f..c0be034 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/EntityInsertBuilder.java +++ b/domain/src/main/java/org/mandarin/booking/domain/EntityInsertBuilder.java @@ -11,6 +11,7 @@ import java.lang.invoke.SerializedLambda; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.lang.reflect.RecordComponent; import java.sql.PreparedStatement; import java.sql.SQLException; import java.util.ArrayList; @@ -22,91 +23,61 @@ public final class EntityInsertBuilder { private final String table; - private final Class recordType; + private final Class dtoType; private final Class entityType; - private final List constants = new ArrayList<>(); - private final List> bindings = new ArrayList<>(); + private final List> binds = new ArrayList<>(); - private EntityInsertBuilder(String table, Class recordType, Class entityType) { + private EntityInsertBuilder(String table, Class dtoType, Class entityType) { this.table = table; - this.recordType = recordType; + this.dtoType = dtoType; this.entityType = entityType; - if (!recordType.isRecord()) { - throw new IllegalArgumentException("recordType must be a record"); - } } public EntityInsertBuilder withForeignKey(Object value) { - String col = resolveSingleJoinColumnName(entityType); - this.constants.add(new Const(col, value)); - return this; - } - - public EntityInsertBuilder bind(Rec recordAccessor, Function mapper) { - String component = componentName(recordAccessor); - String column = resolveColumnNameByField(entityType, component); - Function extractor = r -> { - try { - @SuppressWarnings("unchecked") - V v = (V) recordType.getMethod(component).invoke(r); - return mapper.apply(v); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException(e); - } - }; - bindings.add(new Binding<>(column, extractor)); + binds.add(ColBinding.constant(resolveSingleJoinColumnName(entityType), value)); return this; } public EntityInsertBuilder bindAs(Rec recordAccessor, String entityFieldName) { String component = componentName(recordAccessor); String column = resolveColumnNameByField(entityType, entityFieldName); - - bindings.add(new Binding<>(column, r -> retrieveEntityIdOrValue(r, component))); + binds.add(ColBinding.of(column, r -> toDbValue(getRecordComponent(r, component)))); return this; } public EntityInsertBuilder autoBindAll() { - for (var rc : recordType.getRecordComponents()) { + for (RecordComponent rc : dtoType.getRecordComponents()) { String name = rc.getName(); - var fOpt = findField(entityType, name); - if (fOpt.isEmpty()) { - continue; - } - var f = fOpt.get(); - if (f.getAnnotation(OneToMany.class) != null || f.getAnnotation(ManyToMany.class) != null) { - continue; - } - if (f.getAnnotation(OneToOne.class) != null && f.getAnnotation(JoinColumn.class) == null) { - continue; - } - - String column = resolveColumnNameByField(entityType, name); - bindings.add(new Binding<>(column, r -> retrieveEntityIdOrValue(r, name))); + findField(entityType, name).ifPresent(f -> { + if (f.isAnnotationPresent(OneToMany.class) + || f.isAnnotationPresent(ManyToMany.class) + || (f.isAnnotationPresent(OneToOne.class) && !f.isAnnotationPresent(JoinColumn.class))) { + return; + } + String column = resolveColumnNameByField(entityType, name); + binds.add(ColBinding.of(column, r -> toDbValue(getRecordComponent(r, name)))); + }); } return this; } public Compiled compile() { - List columns = new ArrayList<>(); - constants.forEach(c -> columns.add(c.column)); - bindings.forEach(b -> columns.add(b.column)); + if (binds.isEmpty()) { + throw new IllegalStateException("No columns bound for INSERT"); + } StringJoiner cols = new StringJoiner(", "); StringJoiner holders = new StringJoiner(", "); - for (String column : columns) { - cols.add(column); + binds.forEach(b -> { + cols.add(b.column()); holders.add("?"); - } - String sql = "INSERT INTO " + table + " (" + cols + ") VALUES (" + holders + ")"; + }); + String sql = "INSERT INTO " + table + " (" + cols + ") VALUES (" + holders + ")"; Binder binder = (ps, item) -> { int idx = 1; - for (Const c : constants) { - setObject(ps, idx++, c.value); - } - for (Binding b : bindings) { - setObject(ps, idx++, b.extractor.apply(item)); + for (ColBinding b : binds) { + ps.setObject(idx++, b.extractor().apply(item)); } }; return new Compiled<>(sql, binder); @@ -116,76 +87,56 @@ public static EntityInsertBuilder forTable(String table, Class r return new EntityInsertBuilder<>(table, recordType, entityType); } - private Object retrieveEntityIdOrValue(R r, String component) { + private Object getRecordComponent(R r, String component) { try { - Object v = recordType.getMethod(component).invoke(r); - if (v instanceof AbstractEntity ae) { - return ae.getId(); - } - return v; + return dtoType.getMethod(component).invoke(r); } catch (ReflectiveOperationException e) { - throw new IllegalStateException(e); + throw new IllegalStateException("Cannot access record component: " + component, e); } } + private static Object toDbValue(Object v) { + return (v instanceof AbstractEntity ae) ? ae.getId() : v; + } + private static String resolveSingleJoinColumnName(Class entityType) { - Field chosen = null; - for (Field f : allFields(entityType)) { - if (f.getAnnotation(JoinColumn.class) != null) { - if (chosen != null) { + return allFields(entityType).stream() + .filter(f -> f.isAnnotationPresent(JoinColumn.class)) + .reduce((a, b) -> { throw new IllegalArgumentException("Multiple @JoinColumn present"); - } - chosen = f; - } - } - if (chosen == null) { - throw new IllegalArgumentException("No @JoinColumn present"); - } - JoinColumn jc = chosen.getAnnotation(JoinColumn.class); - if (jc != null && !jc.name().isBlank()) { - return jc.name(); - } - return toSnakeCase(chosen.getName()) + "_id"; + }) + .map(f -> { + JoinColumn jc = f.getAnnotation(JoinColumn.class); + if (!jc.name().isBlank()) { + return jc.name(); + } + return toSnakeCase(f.getName()) + "_id"; + }) + .orElseThrow(() -> new IllegalArgumentException("No @JoinColumn present")); } private static String resolveColumnNameByField(Class entityType, String fieldName) { Field f = findField(entityType, fieldName) .orElseThrow(() -> new IllegalArgumentException("Unknown entity field: " + fieldName)); - JoinColumn jc = f.getAnnotation(JoinColumn.class); - if (jc != null && !jc.name().isBlank()) { - return jc.name(); + if (f.isAnnotationPresent(JoinColumn.class) && !f.getAnnotation(JoinColumn.class).name().isBlank()) { + return f.getAnnotation(JoinColumn.class).name(); } - Column c = f.getAnnotation(Column.class); - if (c != null && !c.name().isBlank()) { - return c.name(); + if (f.isAnnotationPresent(Column.class) && !f.getAnnotation(Column.class).name().isBlank()) { + return f.getAnnotation(Column.class).name(); } return toSnakeCase(fieldName); } - private static List allFields(Class type) { - List list = new ArrayList<>(); - Class t = type; - while (t != null && t != Object.class) { - list.addAll(Arrays.asList(t.getDeclaredFields())); - t = t.getSuperclass(); - } - return list; - } - private static Optional findField(Class type, String name) { return allFields(type).stream().filter(f -> f.getName().equals(name)).findFirst(); } - private static void setObject(PreparedStatement ps, int idx, Object value) throws SQLException { - switch (value) { - case String s -> ps.setString(idx, s); - case Integer i -> ps.setInt(idx, i); - case Long l -> ps.setLong(idx, l); - case Boolean b -> ps.setBoolean(idx, b); - case Double d -> ps.setDouble(idx, d); - case Float f -> ps.setFloat(idx, f); - case null, default -> ps.setObject(idx, value); + private static List allFields(Class type) { + List list = new ArrayList<>(); + for (Class t = type; t != Object.class; t = t.getSuperclass()) { + list.addAll(Arrays.asList(t.getDeclaredFields())); } + return list; } private static String componentName(Serializable lambda) { @@ -211,9 +162,13 @@ public interface Binder { public record Compiled(String sql, Binder binder) { } - private record Const(String column, Object value) { - } + private record ColBinding(String column, Function extractor) { + static ColBinding of(String column, Function extractor) { + return new ColBinding<>(column, extractor); + } - private record Binding(String column, Function extractor) { + static ColBinding constant(String column, Object value) { + return new ColBinding<>(column, r -> value); + } } } diff --git a/domain/src/test/java/org/mandarin/booking/domain/EntityInsertBuilderTest.java b/domain/src/test/java/org/mandarin/booking/domain/EntityInsertBuilderTest.java new file mode 100644 index 0000000..23b8467 --- /dev/null +++ b/domain/src/test/java/org/mandarin/booking/domain/EntityInsertBuilderTest.java @@ -0,0 +1,294 @@ +package org.mandarin.booking.domain; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import jakarta.persistence.Column; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import java.sql.PreparedStatement; +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.domain.EntityInsertBuilder.Rec; +import org.mockito.InOrder; + +class EntityInsertBuilderTest { + + @Test + @DisplayName("withForeignKey: named @JoinColumn 사용") + void foreignKey_usesNamedJoinColumn() throws Exception { + var compiled = EntityInsertBuilder.forTable("t", RecValue.class, EntFkNamed.class) + .withForeignKey(7L) + .compile(); + assertTrue(compiled.sql().startsWith("INSERT INTO t (fk_named")); + + PreparedStatement ps = mock(PreparedStatement.class); + compiled.binder().bind(ps, new RecValue("a", "b")); + verify(ps).setObject(1, 7L); + } + + @Test + @DisplayName("withForeignKey: 공백 이름은 snake_case + _id로 대체") + void foreignKey_blankName_fallsBack() throws Exception { + var compiled = EntityInsertBuilder.forTable("t", RecValue.class, EntFkBlank.class) + .withForeignKey(9L) + .compile(); + assertTrue(compiled.sql().contains("parent_field_id")); + PreparedStatement ps = mock(PreparedStatement.class); + compiled.binder().bind(ps, new RecValue("a", "b")); + verify(ps).setObject(1, 9L); + } + + @Test + @DisplayName("withForeignKey: @JoinColumn 미존재시 예외") + void foreignKey_noJoin_throws() { + assertThrows(IllegalArgumentException.class, () -> + EntityInsertBuilder.forTable("t", RecValue.class, EntNoJoin.class) + .withForeignKey(1L) + .compile() + ); + } + + @Test + @DisplayName("withForeignKey: @JoinColumn 둘 이상이면 예외") + void foreignKey_multipleJoin_throws() { + assertThrows(IllegalArgumentException.class, () -> + EntityInsertBuilder.forTable("t", RecValue.class, EntMultiJoin.class) + .withForeignKey(1L) + .compile() + ); + } + + @Test + @DisplayName("autoBindAll: 컬렉션/비소유 연관 스킵 + AbstractEntity는 id 매핑") + void autoBindAll_skipsAndMapsId() throws Exception { + var compiled = EntityInsertBuilder.forTable("t", RecChild.class, EntFull.class) + .autoBindAll() + .compile(); + assertTrue(compiled.sql().contains("child_id")); + assertTrue(compiled.sql().contains("custom_name")); + assertFalse(compiled.sql().contains("items")); + assertFalse(compiled.sql().contains("oo")); + + var child = new MyEntity(); + var idField = AbstractEntity.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(child, 42L); + + PreparedStatement ps = mock(PreparedStatement.class); + compiled.binder().bind(ps, new RecChild(child, "nm", List.of("1"), "x")); + InOrder in = inOrder(ps); + in.verify(ps).setObject(1, 42L); + in.verify(ps).setObject(2, "nm"); + } + + @Test + @DisplayName("bind + mapper + 기본/Column 명 매핑 확인") + void bind_and_mapper_and_columnResolution() throws Exception { + var compiled = EntityInsertBuilder.forTable("t", RecValue.class, EntNoJoin.class) + .bindAs(RecValue::someField, "someField") + .bindAs(RecValue::snakeCaseField, "snakeCaseField") + .compile(); + assertTrue(compiled.sql().contains("custom_col")); + assertTrue(compiled.sql().contains("snake_case_field")); + + PreparedStatement ps = mock(PreparedStatement.class); + compiled.binder().bind(ps, new RecValue("v1", "v2")); + InOrder in = inOrder(ps); + in.verify(ps).setObject(1, "v1"); + in.verify(ps).setObject(2, "v2"); + } + + @Test + @DisplayName("autoBindAll: @ManyToMany는 스킵, 다른 필드는 포함") + void autoBindAll_manyToMany_skipped() { + var compiled = EntityInsertBuilder.forTable("t", RecWithManyToMany.class, EntWithManyToMany.class) + .autoBindAll() + .compile(); + assertTrue(compiled.sql().contains("nm_col")); + assertFalse(compiled.sql().contains("tags")); + } + + @Test + @DisplayName("autoBindAll: @OneToOne + @JoinColumn은 포함") + void autoBindAll_oneToOne_withJoin_included() throws Exception { + var compiled = EntityInsertBuilder.forTable("t", RecOO.class, EntOOJoin.class) + .autoBindAll() + .compile(); + assertTrue(compiled.sql().contains("oo_id")); + + var other = new Other(); + var idField = AbstractEntity.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(other, 11L); + + PreparedStatement ps = mock(PreparedStatement.class); + compiled.binder().bind(ps, new RecOO(other)); + verify(ps).setObject(1, 11L); + } + + @Test + @DisplayName("componentName 예외 경로: writeReplace 없는 익명 구현 전달 시 IllegalStateException") + void componentName_catchPath() { + Rec<@NonNull RecValue, @NonNull String> bad = new Rec<>() { + @Override + public String apply(RecValue rec) { + return rec.someField(); + } + }; + assertThrows(IllegalStateException.class, () -> + EntityInsertBuilder.forTable("t", RecValue.class, EntNoJoin.class) + .bindAs(bad, "someField") + ); + } + + @Test + @DisplayName("autoBindAll: @OneToOne 이지만 @JoinColumn 없는 필드는 스킵") + void autoBindAll_oneToOne_withoutJoin_skipped() { + var compiled = EntityInsertBuilder.forTable("t", RecOO_NoJoin.class, EntOO_NoJoin.class) + .autoBindAll() + .compile(); + assertTrue(compiled.sql().contains("nm_col")); + assertFalse(compiled.sql().contains("oo")); + } + + @Test + @DisplayName("resolveColumnNameByField: @JoinColumn name 공백이면 snake_case로 매핑") + void joinColumn_blank_onField_fallsBackToSnakeCase() throws Exception { + var compiled = EntityInsertBuilder.forTable("t", RecJoinBlank.class, EntJoinBlankField.class) + .autoBindAll() + .compile(); + assertTrue(compiled.sql().contains("child_ref")); + + var child = new MyEntity(); + var idField = AbstractEntity.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(child, 5L); + + PreparedStatement ps = mock(PreparedStatement.class); + compiled.binder().bind(ps, new RecJoinBlank(child)); + verify(ps).setObject(1, 5L); + } + + @Test + @DisplayName("getRecordComponent 예외 분기: 레코드 접근이 아닌 람다") + void getRecordComponent_exceptionPath() { + var compiled = EntityInsertBuilder.forTable("t", RecValue.class, EntNoJoin.class) + .bindAs(rv -> "CONST", "someField") + .compile(); + PreparedStatement ps = mock(PreparedStatement.class); + assertThrows(IllegalStateException.class, () -> + compiled.binder().bind(ps, new RecValue("v1", "v2")) + ); + } + + @Test + @DisplayName("바인딩이 하나도 없으면 컴파일 시 예외") + void noBindings_compile_throws() { + assertThrows(IllegalStateException.class, () -> + EntityInsertBuilder.forTable("t", RecValue.class, EntNoJoin.class) + .compile() + ); + } + + @Test + @DisplayName("bindAs: 존재하지 않는 필드명 매핑 시 예외") + void bindAs_unknownField_throws() { + assertThrows(IllegalArgumentException.class, () -> + EntityInsertBuilder.forTable("t", RecValue.class, EntNoJoin.class) + .bindAs(RecValue::someField, "unknown") + ); + } + + static class EntFkNamed extends AbstractEntity { + @JoinColumn(name = "fk_named") + Long parent; + } + + static class EntFkBlank extends AbstractEntity { + @JoinColumn + Long parentField; + } + + static class EntNoJoin extends AbstractEntity { + @Column(name = "custom_col") + String someField; + String snakeCaseField; + } + + static class EntMultiJoin extends AbstractEntity { + @JoinColumn + Long a; + @JoinColumn + Long b; + } + + static class MyEntity extends AbstractEntity { + } + + static class Other extends AbstractEntity { + } + + static class EntFull extends AbstractEntity { + @JoinColumn(name = "child_id") + MyEntity child; + @Column(name = "custom_name") + String name; + @OneToMany + List items; + @OneToOne + Other oo; + String snakeCaseField; + } + + record RecValue(String someField, String snakeCaseField) { + } + + record RecChild(MyEntity child, String name, List items, String extra) { + } + + static class EntJoinBlankField extends AbstractEntity { + @JoinColumn(name = "") + MyEntity childRef; + } + + record RecJoinBlank(MyEntity childRef) { + } + + static class EntWithManyToMany extends AbstractEntity { + @Column(name = "nm_col") + String name; + @ManyToMany + List tags; + } + + record RecWithManyToMany(String name, List tags) { + } + + static class EntOOJoin extends AbstractEntity { + @OneToOne + @JoinColumn(name = "oo_id") + Other oo; + } + + record RecOO(Other oo) { + } + + static class EntOO_NoJoin extends AbstractEntity { + @OneToOne + Other oo; + @Column(name = "nm_col") + String name; + } + + record RecOO_NoJoin(Other oo, String name) { + } +} From 4b8db9d54cfa65e1a51401d7046b6de17ca10a22 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 8 Nov 2025 09:13:34 +0900 Subject: [PATCH 35/38] refactor Grade, Hall, Inventory, Show, and ShowFixture to streamline method definitions and improve code organization --- .../mandarin/booking/utils/ShowFixture.java | 16 ++-- .../mandarin/booking/utils/TestFixture.java | 83 +++++++++---------- .../mandarin/booking/domain/hall/Hall.java | 14 ++-- .../mandarin/booking/domain/show/Grade.java | 8 +- .../booking/domain/show/Inventory.java | 20 ++--- .../mandarin/booking/domain/show/Show.java | 55 ++++++------ 6 files changed, 98 insertions(+), 98 deletions(-) diff --git a/application/src/test/java/org/mandarin/booking/utils/ShowFixture.java b/application/src/test/java/org/mandarin/booking/utils/ShowFixture.java index 9dfc3bf..e0d4bb1 100644 --- a/application/src/test/java/org/mandarin/booking/utils/ShowFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/ShowFixture.java @@ -16,14 +16,6 @@ public class ShowFixture { private static final Random random = new Random(); - 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)) - ); - } - public static ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Long showId, Long sectionId, Map> gradeSeatMap) { @@ -57,6 +49,14 @@ public static SeatUsageRequest getSeatUsageRequest(long sectionId, Map generateGradeRequest(int count) { return IntStream.range(0, count) .mapToObj(i -> new GradeRequest( 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 2ec967b..94025ac 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -225,47 +225,6 @@ public void generateShows(int showCount, int before, int after) { batchInsertShows(rows); } - private Hall insertHallGraph(String hallName, String userId, List sections) { - var hall = new Hall(hallName, userId); - entityManager.persist(hall); - entityManager.flush(); - long hallId = hall.getId(); - - var sectionInsert = forTable( - "section", - SectionRegisterRequest.class, - Section.class) - .withForeignKey(hallId) - .bindAs(SectionRegisterRequest::sectionName, "name") - .compile(); - jdbcBatchUtils.batchUpdate(sectionInsert.sql(), sections, (ps, s) -> sectionInsert.binder().bind(ps, s), 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; - } - public Show generateShow(List grades) { var hall = insertDummyHall(generateUserId()); var hallId = hall.getId(); @@ -341,7 +300,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) @@ -384,6 +342,47 @@ public Inventory findInventoryByScheduleId(Long 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(); + + var sectionInsert = forTable( + "section", + SectionRegisterRequest.class, + Section.class) + .withForeignKey(hallId) + .bindAs(SectionRegisterRequest::sectionName, "name") + .compile(); + jdbcBatchUtils.batchUpdate(sectionInsert.sql(), sections, (ps, s) -> sectionInsert.binder().bind(ps, s), 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(); 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 d3389ca..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 @@ -41,13 +41,6 @@ public List getSeatsBySectionIdAndSeatIds(Long sectionId, List seatI .toList(); } - 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 boolean hasSectionOf(Long sectionId) { return sections.stream().anyMatch(section -> section.getId().equals(sectionId)); } @@ -68,6 +61,13 @@ public void clearSeats() { } } + 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/show/Grade.java b/domain/src/main/java/org/mandarin/booking/domain/show/Grade.java index 45b6219..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 @@ -32,11 +32,11 @@ public 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 index e23d6b6..5da1679 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/Inventory.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/Inventory.java @@ -29,6 +29,16 @@ public class Inventory extends AbstractEntity { 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; @@ -41,14 +51,4 @@ public static Inventory create(Long showScheduleId, Map> seatAs inventory.states.addAll(seatStates); return inventory; } - - public List extractSeatStateRows() { - return states.stream() - .map(SeatState::extractRow) - .toList(); - } - - public void clearSeatStates() { - this.states.clear(); - } } 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 5c3b773..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 @@ -72,33 +72,6 @@ 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 ShowSchedule registerSchedule(ShowScheduleCreateCommand command) { if (!isInSchedule(command.startAt(), command.endAt())) { throw new ShowException("BAD_REQUEST", "공연 기간 범위를 벗어나는 일정입니다."); @@ -146,6 +119,33 @@ public Grade getGradeById(Long gradeId) { .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); } @@ -164,6 +164,7 @@ public enum Type { public enum Rating { ALL, AGE12, AGE15, AGE18 } + @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) public static class ShowCreateCommand { From 6f934cfbe72f512613e2caf6054c68769e780a5d Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 9 Nov 2025 00:43:24 +0900 Subject: [PATCH 36/38] add generated source directory and update build.gradle for Querydsl-EntityQL --- build.gradle | 1 + domain/.gitignore | 1 + domain/build.gradle | 24 +++++++++++++++++++----- 3 files changed, 21 insertions(+), 5 deletions(-) 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/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..564ffee 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' + api 'com.github.eXsio:querydsl-entityql:3.2.0' + 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 } From 4e967c3cbfc4551c0f78d0948461369d080fa572 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Tue, 11 Nov 2025 20:19:17 +0900 Subject: [PATCH 37/38] refactor repositories to replace EntityInsertBuilder with direct JDBC usage for batch inserts --- .../app/hall/HallCommandRepository.java | 19 +- .../app/show/InventoryCommandRepository.java | 20 +- .../mandarin/booking/utils/TestFixture.java | 43 ++- domain/build.gradle | 2 +- .../booking/domain/EntityInsertBuilder.java | 174 ----------- .../domain/EntityInsertBuilderTest.java | 294 ------------------ 6 files changed, 54 insertions(+), 498 deletions(-) delete mode 100644 domain/src/main/java/org/mandarin/booking/domain/EntityInsertBuilder.java delete mode 100644 domain/src/test/java/org/mandarin/booking/domain/EntityInsertBuilderTest.java 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 48f3165..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,12 +1,11 @@ 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.EntityInsertBuilder; import org.mandarin.booking.domain.hall.Hall; import org.mandarin.booking.domain.hall.Hall.SeatInsertRow; -import org.mandarin.booking.domain.hall.Seat; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -28,10 +27,16 @@ Hall insert(Hall hall) { return saved; } - private void batchInsertSeats(List rows) { - var compiled = EntityInsertBuilder.forTable("seat", SeatInsertRow.class, Seat.class) - .autoBindAll() - .compile(); - jdbcBatchUtils.batchUpdate(compiled.sql(), rows, (ps, row) -> compiled.binder().bind(ps, row), 1000); + 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/show/InventoryCommandRepository.java b/application/src/main/java/org/mandarin/booking/app/show/InventoryCommandRepository.java index 7ea8cdf..3550f21 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/InventoryCommandRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/InventoryCommandRepository.java @@ -1,11 +1,10 @@ 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.EntityInsertBuilder; import org.mandarin.booking.domain.show.Inventory; -import org.mandarin.booking.domain.show.SeatState; import org.mandarin.booking.domain.show.SeatState.SeatStateRow; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -26,11 +25,16 @@ void insert(Inventory inventory) { batchInsert(inventory.getId(), rows); } - void batchInsert(Long inventoryId, List rows) { - var compiled = EntityInsertBuilder.forTable("seat_state", SeatStateRow.class, SeatState.class) - .withForeignKey(inventoryId) - .autoBindAll() - .compile(); - jdbcBatchUtils.batchUpdate(compiled.sql(), rows, (ps, row) -> compiled.binder().bind(ps, row), 1000); + 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/test/java/org/mandarin/booking/utils/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java index 94025ac..e5457ed 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -1,7 +1,6 @@ package org.mandarin.booking.utils; import static org.mandarin.booking.MemberAuthority.ADMIN; -import static org.mandarin.booking.domain.EntityInsertBuilder.forTable; import static org.mandarin.booking.utils.EnumFixture.randomEnum; import static org.mandarin.booking.utils.HallFixture.generateHallName; import static org.mandarin.booking.utils.HallFixture.generateSectionRegisterRequest; @@ -22,7 +21,6 @@ import org.mandarin.booking.MemberAuthority; import org.mandarin.booking.app.JdbcBatchUtils; import org.mandarin.booking.domain.hall.Hall; -import org.mandarin.booking.domain.hall.Section; import org.mandarin.booking.domain.hall.SectionRegisterRequest; import org.mandarin.booking.domain.member.Member; import org.mandarin.booking.domain.member.Member.MemberCreateCommand; @@ -348,14 +346,17 @@ private Hall insertHallGraph(String hallName, String userId, List sectionInsert.binder().bind(ps, s), 1000); + 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() @@ -450,10 +451,24 @@ private void batchInsertShows(List rows) { if (rows == null || rows.isEmpty()) { return; } - var compiled = forTable("shows", ShowRow.class, Show.class) - .autoBindAll() - .compile(); - jdbcBatchUtils.batchUpdate(compiled.sql(), rows, (ps, row) -> compiled.binder().bind(ps, row), 1000); + 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, diff --git a/domain/build.gradle b/domain/build.gradle index 564ffee..a71beda 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -17,7 +17,7 @@ dependencies { api 'com.querydsl:querydsl-sql:5.1.0' api 'com.querydsl:querydsl-jpa:5.1.0:jakarta' - api 'com.github.eXsio:querydsl-entityql:3.2.0' + // entityql 제거: 직접 JDBC 사용으로 대체 annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' annotationProcessor 'jakarta.persistence:jakarta.persistence-api:3.1.0' diff --git a/domain/src/main/java/org/mandarin/booking/domain/EntityInsertBuilder.java b/domain/src/main/java/org/mandarin/booking/domain/EntityInsertBuilder.java deleted file mode 100644 index c0be034..0000000 --- a/domain/src/main/java/org/mandarin/booking/domain/EntityInsertBuilder.java +++ /dev/null @@ -1,174 +0,0 @@ -package org.mandarin.booking.domain; - -import static org.mandarin.booking.StringFormatterUtils.toSnakeCase; - -import jakarta.persistence.Column; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToMany; -import jakarta.persistence.OneToMany; -import jakarta.persistence.OneToOne; -import java.io.Serializable; -import java.lang.invoke.SerializedLambda; -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.lang.reflect.RecordComponent; -import java.sql.PreparedStatement; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import java.util.StringJoiner; -import java.util.function.Function; - -public final class EntityInsertBuilder { - private final String table; - private final Class dtoType; - private final Class entityType; - private final List> binds = new ArrayList<>(); - - private EntityInsertBuilder(String table, Class dtoType, Class entityType) { - this.table = table; - this.dtoType = dtoType; - this.entityType = entityType; - } - - public EntityInsertBuilder withForeignKey(Object value) { - binds.add(ColBinding.constant(resolveSingleJoinColumnName(entityType), value)); - return this; - } - - public EntityInsertBuilder bindAs(Rec recordAccessor, String entityFieldName) { - String component = componentName(recordAccessor); - String column = resolveColumnNameByField(entityType, entityFieldName); - binds.add(ColBinding.of(column, r -> toDbValue(getRecordComponent(r, component)))); - return this; - } - - public EntityInsertBuilder autoBindAll() { - for (RecordComponent rc : dtoType.getRecordComponents()) { - String name = rc.getName(); - findField(entityType, name).ifPresent(f -> { - if (f.isAnnotationPresent(OneToMany.class) - || f.isAnnotationPresent(ManyToMany.class) - || (f.isAnnotationPresent(OneToOne.class) && !f.isAnnotationPresent(JoinColumn.class))) { - return; - } - String column = resolveColumnNameByField(entityType, name); - binds.add(ColBinding.of(column, r -> toDbValue(getRecordComponent(r, name)))); - }); - } - return this; - } - - public Compiled compile() { - if (binds.isEmpty()) { - throw new IllegalStateException("No columns bound for INSERT"); - } - - StringJoiner cols = new StringJoiner(", "); - StringJoiner holders = new StringJoiner(", "); - binds.forEach(b -> { - cols.add(b.column()); - holders.add("?"); - }); - - String sql = "INSERT INTO " + table + " (" + cols + ") VALUES (" + holders + ")"; - Binder binder = (ps, item) -> { - int idx = 1; - for (ColBinding b : binds) { - ps.setObject(idx++, b.extractor().apply(item)); - } - }; - return new Compiled<>(sql, binder); - } - - public static EntityInsertBuilder forTable(String table, Class recordType, Class entityType) { - return new EntityInsertBuilder<>(table, recordType, entityType); - } - - private Object getRecordComponent(R r, String component) { - try { - return dtoType.getMethod(component).invoke(r); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("Cannot access record component: " + component, e); - } - } - - private static Object toDbValue(Object v) { - return (v instanceof AbstractEntity ae) ? ae.getId() : v; - } - - private static String resolveSingleJoinColumnName(Class entityType) { - return allFields(entityType).stream() - .filter(f -> f.isAnnotationPresent(JoinColumn.class)) - .reduce((a, b) -> { - throw new IllegalArgumentException("Multiple @JoinColumn present"); - }) - .map(f -> { - JoinColumn jc = f.getAnnotation(JoinColumn.class); - if (!jc.name().isBlank()) { - return jc.name(); - } - return toSnakeCase(f.getName()) + "_id"; - }) - .orElseThrow(() -> new IllegalArgumentException("No @JoinColumn present")); - } - - private static String resolveColumnNameByField(Class entityType, String fieldName) { - Field f = findField(entityType, fieldName) - .orElseThrow(() -> new IllegalArgumentException("Unknown entity field: " + fieldName)); - if (f.isAnnotationPresent(JoinColumn.class) && !f.getAnnotation(JoinColumn.class).name().isBlank()) { - return f.getAnnotation(JoinColumn.class).name(); - } - if (f.isAnnotationPresent(Column.class) && !f.getAnnotation(Column.class).name().isBlank()) { - return f.getAnnotation(Column.class).name(); - } - return toSnakeCase(fieldName); - } - - private static Optional findField(Class type, String name) { - return allFields(type).stream().filter(f -> f.getName().equals(name)).findFirst(); - } - - private static List allFields(Class type) { - List list = new ArrayList<>(); - for (Class t = type; t != Object.class; t = t.getSuperclass()) { - list.addAll(Arrays.asList(t.getDeclaredFields())); - } - return list; - } - - private static String componentName(Serializable lambda) { - try { - Method m = lambda.getClass().getDeclaredMethod("writeReplace"); - m.setAccessible(true); - SerializedLambda sl = (SerializedLambda) m.invoke(lambda); - return sl.getImplMethodName(); - } catch (Exception e) { - throw new IllegalStateException("Cannot resolve component name", e); - } - } - - @FunctionalInterface - public interface Rec extends Function, Serializable { - } - - @FunctionalInterface - public interface Binder { - void bind(PreparedStatement ps, R item) throws SQLException; - } - - public record Compiled(String sql, Binder binder) { - } - - private record ColBinding(String column, Function extractor) { - static ColBinding of(String column, Function extractor) { - return new ColBinding<>(column, extractor); - } - - static ColBinding constant(String column, Object value) { - return new ColBinding<>(column, r -> value); - } - } -} diff --git a/domain/src/test/java/org/mandarin/booking/domain/EntityInsertBuilderTest.java b/domain/src/test/java/org/mandarin/booking/domain/EntityInsertBuilderTest.java deleted file mode 100644 index 23b8467..0000000 --- a/domain/src/test/java/org/mandarin/booking/domain/EntityInsertBuilderTest.java +++ /dev/null @@ -1,294 +0,0 @@ -package org.mandarin.booking.domain; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.inOrder; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - -import jakarta.persistence.Column; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToMany; -import jakarta.persistence.OneToMany; -import jakarta.persistence.OneToOne; -import java.sql.PreparedStatement; -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.domain.EntityInsertBuilder.Rec; -import org.mockito.InOrder; - -class EntityInsertBuilderTest { - - @Test - @DisplayName("withForeignKey: named @JoinColumn 사용") - void foreignKey_usesNamedJoinColumn() throws Exception { - var compiled = EntityInsertBuilder.forTable("t", RecValue.class, EntFkNamed.class) - .withForeignKey(7L) - .compile(); - assertTrue(compiled.sql().startsWith("INSERT INTO t (fk_named")); - - PreparedStatement ps = mock(PreparedStatement.class); - compiled.binder().bind(ps, new RecValue("a", "b")); - verify(ps).setObject(1, 7L); - } - - @Test - @DisplayName("withForeignKey: 공백 이름은 snake_case + _id로 대체") - void foreignKey_blankName_fallsBack() throws Exception { - var compiled = EntityInsertBuilder.forTable("t", RecValue.class, EntFkBlank.class) - .withForeignKey(9L) - .compile(); - assertTrue(compiled.sql().contains("parent_field_id")); - PreparedStatement ps = mock(PreparedStatement.class); - compiled.binder().bind(ps, new RecValue("a", "b")); - verify(ps).setObject(1, 9L); - } - - @Test - @DisplayName("withForeignKey: @JoinColumn 미존재시 예외") - void foreignKey_noJoin_throws() { - assertThrows(IllegalArgumentException.class, () -> - EntityInsertBuilder.forTable("t", RecValue.class, EntNoJoin.class) - .withForeignKey(1L) - .compile() - ); - } - - @Test - @DisplayName("withForeignKey: @JoinColumn 둘 이상이면 예외") - void foreignKey_multipleJoin_throws() { - assertThrows(IllegalArgumentException.class, () -> - EntityInsertBuilder.forTable("t", RecValue.class, EntMultiJoin.class) - .withForeignKey(1L) - .compile() - ); - } - - @Test - @DisplayName("autoBindAll: 컬렉션/비소유 연관 스킵 + AbstractEntity는 id 매핑") - void autoBindAll_skipsAndMapsId() throws Exception { - var compiled = EntityInsertBuilder.forTable("t", RecChild.class, EntFull.class) - .autoBindAll() - .compile(); - assertTrue(compiled.sql().contains("child_id")); - assertTrue(compiled.sql().contains("custom_name")); - assertFalse(compiled.sql().contains("items")); - assertFalse(compiled.sql().contains("oo")); - - var child = new MyEntity(); - var idField = AbstractEntity.class.getDeclaredField("id"); - idField.setAccessible(true); - idField.set(child, 42L); - - PreparedStatement ps = mock(PreparedStatement.class); - compiled.binder().bind(ps, new RecChild(child, "nm", List.of("1"), "x")); - InOrder in = inOrder(ps); - in.verify(ps).setObject(1, 42L); - in.verify(ps).setObject(2, "nm"); - } - - @Test - @DisplayName("bind + mapper + 기본/Column 명 매핑 확인") - void bind_and_mapper_and_columnResolution() throws Exception { - var compiled = EntityInsertBuilder.forTable("t", RecValue.class, EntNoJoin.class) - .bindAs(RecValue::someField, "someField") - .bindAs(RecValue::snakeCaseField, "snakeCaseField") - .compile(); - assertTrue(compiled.sql().contains("custom_col")); - assertTrue(compiled.sql().contains("snake_case_field")); - - PreparedStatement ps = mock(PreparedStatement.class); - compiled.binder().bind(ps, new RecValue("v1", "v2")); - InOrder in = inOrder(ps); - in.verify(ps).setObject(1, "v1"); - in.verify(ps).setObject(2, "v2"); - } - - @Test - @DisplayName("autoBindAll: @ManyToMany는 스킵, 다른 필드는 포함") - void autoBindAll_manyToMany_skipped() { - var compiled = EntityInsertBuilder.forTable("t", RecWithManyToMany.class, EntWithManyToMany.class) - .autoBindAll() - .compile(); - assertTrue(compiled.sql().contains("nm_col")); - assertFalse(compiled.sql().contains("tags")); - } - - @Test - @DisplayName("autoBindAll: @OneToOne + @JoinColumn은 포함") - void autoBindAll_oneToOne_withJoin_included() throws Exception { - var compiled = EntityInsertBuilder.forTable("t", RecOO.class, EntOOJoin.class) - .autoBindAll() - .compile(); - assertTrue(compiled.sql().contains("oo_id")); - - var other = new Other(); - var idField = AbstractEntity.class.getDeclaredField("id"); - idField.setAccessible(true); - idField.set(other, 11L); - - PreparedStatement ps = mock(PreparedStatement.class); - compiled.binder().bind(ps, new RecOO(other)); - verify(ps).setObject(1, 11L); - } - - @Test - @DisplayName("componentName 예외 경로: writeReplace 없는 익명 구현 전달 시 IllegalStateException") - void componentName_catchPath() { - Rec<@NonNull RecValue, @NonNull String> bad = new Rec<>() { - @Override - public String apply(RecValue rec) { - return rec.someField(); - } - }; - assertThrows(IllegalStateException.class, () -> - EntityInsertBuilder.forTable("t", RecValue.class, EntNoJoin.class) - .bindAs(bad, "someField") - ); - } - - @Test - @DisplayName("autoBindAll: @OneToOne 이지만 @JoinColumn 없는 필드는 스킵") - void autoBindAll_oneToOne_withoutJoin_skipped() { - var compiled = EntityInsertBuilder.forTable("t", RecOO_NoJoin.class, EntOO_NoJoin.class) - .autoBindAll() - .compile(); - assertTrue(compiled.sql().contains("nm_col")); - assertFalse(compiled.sql().contains("oo")); - } - - @Test - @DisplayName("resolveColumnNameByField: @JoinColumn name 공백이면 snake_case로 매핑") - void joinColumn_blank_onField_fallsBackToSnakeCase() throws Exception { - var compiled = EntityInsertBuilder.forTable("t", RecJoinBlank.class, EntJoinBlankField.class) - .autoBindAll() - .compile(); - assertTrue(compiled.sql().contains("child_ref")); - - var child = new MyEntity(); - var idField = AbstractEntity.class.getDeclaredField("id"); - idField.setAccessible(true); - idField.set(child, 5L); - - PreparedStatement ps = mock(PreparedStatement.class); - compiled.binder().bind(ps, new RecJoinBlank(child)); - verify(ps).setObject(1, 5L); - } - - @Test - @DisplayName("getRecordComponent 예외 분기: 레코드 접근이 아닌 람다") - void getRecordComponent_exceptionPath() { - var compiled = EntityInsertBuilder.forTable("t", RecValue.class, EntNoJoin.class) - .bindAs(rv -> "CONST", "someField") - .compile(); - PreparedStatement ps = mock(PreparedStatement.class); - assertThrows(IllegalStateException.class, () -> - compiled.binder().bind(ps, new RecValue("v1", "v2")) - ); - } - - @Test - @DisplayName("바인딩이 하나도 없으면 컴파일 시 예외") - void noBindings_compile_throws() { - assertThrows(IllegalStateException.class, () -> - EntityInsertBuilder.forTable("t", RecValue.class, EntNoJoin.class) - .compile() - ); - } - - @Test - @DisplayName("bindAs: 존재하지 않는 필드명 매핑 시 예외") - void bindAs_unknownField_throws() { - assertThrows(IllegalArgumentException.class, () -> - EntityInsertBuilder.forTable("t", RecValue.class, EntNoJoin.class) - .bindAs(RecValue::someField, "unknown") - ); - } - - static class EntFkNamed extends AbstractEntity { - @JoinColumn(name = "fk_named") - Long parent; - } - - static class EntFkBlank extends AbstractEntity { - @JoinColumn - Long parentField; - } - - static class EntNoJoin extends AbstractEntity { - @Column(name = "custom_col") - String someField; - String snakeCaseField; - } - - static class EntMultiJoin extends AbstractEntity { - @JoinColumn - Long a; - @JoinColumn - Long b; - } - - static class MyEntity extends AbstractEntity { - } - - static class Other extends AbstractEntity { - } - - static class EntFull extends AbstractEntity { - @JoinColumn(name = "child_id") - MyEntity child; - @Column(name = "custom_name") - String name; - @OneToMany - List items; - @OneToOne - Other oo; - String snakeCaseField; - } - - record RecValue(String someField, String snakeCaseField) { - } - - record RecChild(MyEntity child, String name, List items, String extra) { - } - - static class EntJoinBlankField extends AbstractEntity { - @JoinColumn(name = "") - MyEntity childRef; - } - - record RecJoinBlank(MyEntity childRef) { - } - - static class EntWithManyToMany extends AbstractEntity { - @Column(name = "nm_col") - String name; - @ManyToMany - List tags; - } - - record RecWithManyToMany(String name, List tags) { - } - - static class EntOOJoin extends AbstractEntity { - @OneToOne - @JoinColumn(name = "oo_id") - Other oo; - } - - record RecOO(Other oo) { - } - - static class EntOO_NoJoin extends AbstractEntity { - @OneToOne - Other oo; - @Column(name = "nm_col") - String name; - } - - record RecOO_NoJoin(Other oo, String name) { - } -} From b9541a702b5247890cbec7ad741a8359854d2813 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Tue, 11 Nov 2025 21:10:06 +0900 Subject: [PATCH 38/38] implement seat registration feature with inventory mapping for performance schedules --- docs/devlog/251111.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/devlog/251111.md 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을 적용할까 생각도 해봤는데 둘다 만족스러운 형태로는 잘 되지 않아서 일단은 현재 형태로 만족하려고 한다.