diff --git a/AGENTS.md b/AGENTS.md index d8166f4..bc37ae4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ * 라이브러리 지시가 필요하면 **이름만**(예: “use Querydsl”). * **사실 우선, 추측 금지.** 근거 파일이 없으면 “확인 불가”. * 충돌 시 **`docs/specs/policy/application.md`** 우선. +* 응답이 필요한 경우 최대한 한글로 답변. ## 1) 스코프 diff --git a/README.md b/README.md index aaebea4..4d9f895 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Build/Test 구성 근거: `build.gradle`의 `tasks.named('test')` 설정(Profile - local: MySQL + JPA `ddl-auto: create`, JWT 시크릿/TTL 설정 - 근거: `application-local.yml`, Docker Compose: [compose.yaml](application/src/main/resources/compose.yaml) - test: H2 메모리 + MySQL 호환 모드 + JPA `ddl-auto: create` - - 근거: `application-test.yml` + - 근거: `application-test.yml` 민감정보는 운영 환경에서 환경변수로 주입하는 것을 권장합니다(로컬에 예시 값 존재). @@ -125,11 +125,11 @@ Build/Test 구성 근거: `build.gradle`의 `tasks.named('test')` 설정(Profile - CI/CD, 코드 포매터, 마이그레이션 도구(Flyway/Liquibase)는 현재 문서/설정 부재로 "확인 불가" 상태입니다. - TODO/메모: [docs/devlog/*](docs/devlog), [docs/todo.md](docs/todo.md) - 권장 향후 작업 - - prod 프로필 구성과 비밀 주입 전략 수립 - - CI 파이프라인(.github/workflows) 도입 - - DB 마이그레이션 도구 채택 및 규약 수립 - - 인증/인가 정책 문서 - 구체화: [docs/specs/policy/authentication.md](docs/specs/policy/authentication.md), [docs/specs/policy/authorization.md](docs/specs/policy/authorization.md) + - prod 프로필 구성과 비밀 주입 전략 수립 + - CI 파이프라인(.github/workflows) 도입 + - DB 마이그레이션 도구 채택 및 규약 수립 + - 인증/인가 정책 문서 + 구체화: [docs/specs/policy/authentication.md](docs/specs/policy/authentication.md), [docs/specs/policy/authorization.md](docs/specs/policy/authorization.md) --- diff --git a/application/src/main/java/org/mandarin/booking/app/NullableQueryFilterBuilder.java b/application/src/main/java/org/mandarin/booking/app/NullableQueryFilterBuilder.java index 93189fb..13b0bb2 100644 --- a/application/src/main/java/org/mandarin/booking/app/NullableQueryFilterBuilder.java +++ b/application/src/main/java/org/mandarin/booking/app/NullableQueryFilterBuilder.java @@ -41,7 +41,7 @@ public NullableQueryFilterBuilder whenHasText(@Nullable String value, Function show.performanceDate.between(f, t)) +// * 예: whenBoth(of, to, (f, t) -> show.performanceDate.between(f, t)) // */ // public NullableQueryFilterBuilder whenBoth(@Nullable L left, @Nullable R right, BiFunction mapper) { // if (left != null && right != null) { 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 8a3dc2e..f9e4685 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 @@ -9,8 +9,7 @@ class HallCommandRepository { private final HallRepository jpaRepository; - - public Hall insert(Hall hall) { + Hall insert(Hall hall) { return jpaRepository.save(hall); } } diff --git a/application/src/main/java/org/mandarin/booking/app/member/MemberValidator.java b/application/src/main/java/org/mandarin/booking/app/member/MemberValidator.java index 4138632..b9e9696 100644 --- a/application/src/main/java/org/mandarin/booking/app/member/MemberValidator.java +++ b/application/src/main/java/org/mandarin/booking/app/member/MemberValidator.java @@ -1,6 +1,6 @@ package org.mandarin.booking.app.member; -public interface MemberValidator { +interface MemberValidator { void checkDuplicateEmail(String email); void checkDuplicateUserId(String userId); diff --git a/application/src/test/java/org/mandarin/booking/adapter/security/BCryptSecurePasswordEncoderTest.java b/application/src/test/java/org/mandarin/booking/adapter/security/BCryptSecurePasswordEncoderTest.java new file mode 100644 index 0000000..1f7bf23 --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/adapter/security/BCryptSecurePasswordEncoderTest.java @@ -0,0 +1,25 @@ +package org.mandarin.booking.adapter.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class BCryptSecurePasswordEncoderTest { + @Test + void encode(@Autowired BCryptSecurePasswordEncoder encoder) { + String rawPassword = "password123"; + String encodedPassword = encoder.encode(rawPassword); + assertThat(encodedPassword).isNotEqualTo(rawPassword); + assertThat(encoder.matches(rawPassword, encodedPassword)).isTrue(); + } + + @Test + void matches(@Autowired BCryptSecurePasswordEncoder encoder) { + String rawPassword = "password123"; + String encodedPassword = encoder.encode(rawPassword); + assertThat(encoder.matches(rawPassword, encodedPassword)).isTrue(); + } +} 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 e8172c1..33da4a3 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -96,7 +96,9 @@ public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanc "synopsis", "https://example.com/poster.jpg", performanceStartDate, - performanceEndDate + performanceEndDate, + "KRW", + List.of(new ShowRegisterRequest.GradeRequest("VIP", 100000, 100)) ) ); var show = Show.create(hall.getId(), command); @@ -174,7 +176,9 @@ public void generateShows(int showCount, int before, int after) { "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now().minusDays(random.nextInt(before)), - LocalDate.now().plusDays(random.nextInt(after)) + LocalDate.now().plusDays(random.nextInt(after)), + "KRW", + List.of(new ShowRegisterRequest.GradeRequest("VIP", 100000, 100)) ); var show = Show.create(hallId, ShowCreateCommand.from(request)); showInsert(show); @@ -191,7 +195,9 @@ public Show generateShowWithNoSynopsis(int scheduleCount) { null, "https://example.com/poster.jpg", LocalDate.now(), - LocalDate.now().plusDays(30) + LocalDate.now().plusDays(30), + "KRW", + List.of(new ShowRegisterRequest.GradeRequest("VIP", 100000, 100)) ))); for (int i = 0; i < scheduleCount; i++) { @@ -215,6 +221,7 @@ public boolean existsHallName(String hallName) { } public void removeShows() { + entityManager.createQuery("DELETE FROM Grade ").executeUpdate(); entityManager.createQuery("DELETE FROM Show ").executeUpdate(); } @@ -230,6 +237,16 @@ public Hall findHallById(Long hallId) { .getSingleResult(); } + public Show findShowByTitle(String title) { + return entityManager.createQuery( + "SELECT s FROM Show s " + + "JOIN FETCH s.grades grade " + + "WHERE s.title = :title", Show.class) + .setParameter("title", 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) @@ -238,6 +255,12 @@ public boolean isMatchingScheduleInShow(ShowScheduleResponse res, Show show) { .getResultList().isEmpty(); } + private Show generateShow(Long hallId) { + var request = validShowRegisterRequest(hallId, randomEnum(Type.class).name(), randomEnum(Rating.class).name()); + var show = Show.create(hallId, ShowCreateCommand.from(request)); + return showInsert(show); + } + private void generateShow(Long hallId, Type type) { var request = validShowRegisterRequest(hallId, type.name(), randomEnum(Rating.class).name()); var show = Show.create(hallId, ShowCreateCommand.from(request)); @@ -253,7 +276,12 @@ private ShowRegisterRequest validShowRegisterRequest(Long hallId, String type, S "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), - LocalDate.now().plusDays(30) + LocalDate.now().plusDays(30), + "KRW", + List.of( + new ShowRegisterRequest.GradeRequest("VIP", 180000, 100), + new ShowRegisterRequest.GradeRequest("R", 150000, 30) + ) ); } @@ -263,12 +291,6 @@ private void generateShow(Long hallId, Rating rating) { showInsert(show); } - public Show generateShow(Long hallId) { - var request = validShowRegisterRequest(hallId, randomEnum(Type.class).name(), randomEnum(Rating.class).name()); - var show = Show.create(hallId, ShowCreateCommand.from(request)); - return showInsert(show); - } - private Show showInsert(Show show) { entityManager.persist(show); return show; diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java index 0db493e..3d1e53f 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java @@ -1,6 +1,7 @@ package org.mandarin.booking.webapi.show; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; import static org.mandarin.booking.MemberAuthority.ADMIN; import static org.mandarin.booking.MemberAuthority.DISTRIBUTOR; import static org.mandarin.booking.adapter.ApiStatus.BAD_REQUEST; @@ -9,6 +10,7 @@ import static org.mandarin.booking.adapter.ApiStatus.NOT_FOUND; import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; import static org.mandarin.booking.adapter.ApiStatus.UNAUTHORIZED; +import static org.mandarin.booking.domain.show.ShowRegisterRequest.GradeRequest; import java.time.LocalDate; import java.util.List; @@ -17,6 +19,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mandarin.booking.domain.show.Show; import org.mandarin.booking.domain.show.ShowRegisterRequest; import org.mandarin.booking.domain.show.ShowRegisterResponse; import org.mandarin.booking.utils.IntegrationTest; @@ -32,21 +36,33 @@ static List nullOrBlankElementRequests() { var hallId = 1L; return List.of( new ShowRegisterRequest(hallId, "", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", - LocalDate.now(), LocalDate.now().plusDays(1)), + LocalDate.now(), LocalDate.now().plusDays(1), + "KRW", List.of(new GradeRequest("VIP", 100000, 100)) + ), new ShowRegisterRequest(hallId, "공연 제목", "", "ALL", "공연 줄거리", "https://example.com/poster.jpg", - LocalDate.now(), - LocalDate.now().plusDays(1)), + LocalDate.now(), LocalDate.now().plusDays(1), + "KRW", List.of(new GradeRequest("VIP", 100000, 100)) + ), new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "", "공연 줄거리", "https://example.com/poster.jpg", - LocalDate.now(), LocalDate.now().plusDays(1)), + LocalDate.now(), LocalDate.now().plusDays(1), + "KRW", List.of(new GradeRequest("VIP", 100000, 100)) + ), new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "", "https://example.com/poster.jpg", - LocalDate.now(), LocalDate.now().plusDays(1)), + LocalDate.now(), LocalDate.now().plusDays(1), + "KRW", List.of(new GradeRequest("VIP", 100000, 100)) + ), new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "공연 줄거리", "", LocalDate.now(), - LocalDate.now().plusDays(1)), + LocalDate.now().plusDays(1), + "KRW", List.of(new GradeRequest("VIP", 100000, 100)) + ), new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", - null, - LocalDate.now().plusDays(1)), + null, LocalDate.now().plusDays(1), + "KRW", List.of(new GradeRequest("VIP", 100000, 100)) + ), new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", - LocalDate.now(), null) + LocalDate.now(), null, + "KRW", List.of(new GradeRequest("VIP", 100000, 100)) + ) ); } @@ -128,7 +144,8 @@ static List nullOrBlankElementRequests() { "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), - LocalDate.now().plusDays(30) + LocalDate.now().plusDays(30), + "KRW", List.of(new GradeRequest("VIP", 100000, 100)) ); // Act @@ -165,6 +182,48 @@ static List nullOrBlankElementRequests() { assertThat(response.getData().showId()).isNotNull(); } + @Test + void 요청한_ticketGrades가_Show에_영속된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var hallId = testFixture.insertDummyHall("userId").getId(); + var request = new ShowRegisterRequest( + hallId, + UUID.randomUUID().toString().substring(0, 10), + "MUSICAL", + "AGE12", + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().plusDays(30), + "KRW", + List.of( + new GradeRequest("VIP", 180000, 100), + new GradeRequest("R", 150000, 30) + ) + ); + + // Act + testUtils.post( + "/api/show", + request + ) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertSuccess(ShowRegisterResponse.class); + + // Assert + Show show = testFixture.findShowByTitle(request.title()); + assertThat(show.getGrades()) + .hasSize(2) + .extracting("name", "basePrice", "quantity") + .containsExactlyInAnyOrder( + tuple("VIP", 180000, 100), + tuple("R", 150000, 30) + ); + } + @Test void 공연_시작일은_공연_종료일_이후면_INTERNAL_SERVER_ERROR이다( @Autowired IntegrationTestUtils testUtils, @@ -181,7 +240,9 @@ static List nullOrBlankElementRequests() { "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), - LocalDate.now().minusDays(1) + LocalDate.now().minusDays(1), + "KRW", + List.of(new GradeRequest("VIP", 100000, 100)) ); // Act @@ -270,6 +331,175 @@ static List nullOrBlankElementRequests() { assertThat(response.getStatus()).isEqualTo(FORBIDDEN); } + @Test + void ticketGrades가_비어있으면_BAD_REQUEST이다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var authToken = testUtils.getAuthToken(ADMIN); + var hallId = testFixture.insertDummyHall("userId").getId(); + var request = new ShowRegisterRequest( + hallId, + UUID.randomUUID().toString().substring(0, 10), + "MUSICAL", + "AGE12", + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().plusDays(30), + "KRW", + List.of() + ); + + // Act + var response = testUtils.post( + "/api/show", + request + ) + .withAuthorization(authToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void ticketGrade의_name이_중복이면_BAD_REQUEST이다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + + var hallId = testFixture.insertDummyHall(testFixture.insertDummyMember(ADMIN).getUserId()).getId(); + var request = new ShowRegisterRequest( + hallId, + UUID.randomUUID().toString().substring(0, 10), + "MUSICAL", + "AGE12", + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().plusDays(30), + "KRW", + List.of( + new GradeRequest("VIP", 180000, 100), + new GradeRequest("VIP", 150000, 30) + ) + ); + + // Act + var response = testUtils.post( + "/api/show", + request + ) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void ticketGrade의_basePrice가_양수가_아니면_BAD_REQUEST이다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var authToken = testUtils.getAuthToken(ADMIN); + var hallId = testFixture.insertDummyHall("userId").getId(); + var request = new ShowRegisterRequest( + hallId, + UUID.randomUUID().toString().substring(0, 10), + "MUSICAL", + "AGE12", + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().plusDays(30), + "KRW", + List.of( + new GradeRequest("VIP", 0, 100) + ) + ); + + // Act + var response = testUtils.post( + "/api/show", + request + ) + .withAuthorization(authToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void quantity가_양수가_아닌_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var hallId = testFixture.insertDummyHall(testFixture.insertDummyMember(ADMIN).getUserId()).getId(); + var request = new ShowRegisterRequest( + hallId, + UUID.randomUUID().toString().substring(0, 10), + "MUSICAL", + "AGE12", + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().plusDays(30), + "KRW", + List.of( + new GradeRequest("VIP", 100000, -1) + ) + ); + + // Act + var response = testUtils.post("/api/show", request) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @ParameterizedTest + @ValueSource(strings = {"", "KW", "EUR", "JPY", "KR", "KOR", "WUN", "123"}) + void currency가_비어있거나_잘못된_값이면_BAD_REQUEST를_반환한다( + String currency, + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var hallId = testFixture.insertDummyHall("userId").getId(); + var request = new ShowRegisterRequest( + hallId, + UUID.randomUUID().toString().substring(0, 10), + "MUSICAL", + "AGE12", + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().plusDays(30), + currency, + List.of( + new GradeRequest("VIP", 180000, 100), + new GradeRequest("R", 150000, 20) + ) + ); + + // Act + var response = testUtils.post("/api/show", request) + .withAuthorization(testUtils.getAuthToken(ADMIN)) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + private ShowRegisterRequest validShowRegisterRequest(Long hallId) { return new ShowRegisterRequest( hallId, @@ -279,7 +509,12 @@ private ShowRegisterRequest validShowRegisterRequest(Long hallId) { "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), - LocalDate.now().plusDays(30) + LocalDate.now().plusDays(30), + "KRW", + List.of( + new GradeRequest("VIP", 180000, 100), + new GradeRequest("R", 150000, 30) + ) ); } @@ -292,8 +527,12 @@ private ShowRegisterRequest validShowRegisterRequest(Long hallId, String title) "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), - LocalDate.now().plusDays(30) + LocalDate.now().plusDays(30), + "KRW", + List.of( + new GradeRequest("VIP", 180000, 100), + new GradeRequest("R", 150000, 30) + ) ); } } - diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/showId/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/showId/GET_specs.java index 83d94b1..b6038cd 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/showId/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/showId/GET_specs.java @@ -47,7 +47,7 @@ class GET_specs { // Arrange var show = testFixture.generateShow(5); var invalidShowId = show.getId() + 9999; - + // Act var response = testUtils.get("/api/show/" + invalidShowId) .assertFailure(); diff --git a/common/src/main/java/org/mandarin/booking/Currency.java b/common/src/main/java/org/mandarin/booking/Currency.java new file mode 100644 index 0000000..40e4db2 --- /dev/null +++ b/common/src/main/java/org/mandarin/booking/Currency.java @@ -0,0 +1,5 @@ +package org.mandarin.booking; + +public enum Currency { + KRW +} diff --git a/docs/devlog/251003.md b/docs/devlog/251003.md new file mode 100644 index 0000000..1e0bee3 --- /dev/null +++ b/docs/devlog/251003.md @@ -0,0 +1,7 @@ +## 예찬 + +공연을 등록할때 등급을 함께 등록해야 한다는 사실을 뒤늦게 인지 후 기능 수정. + +가능한 최소한의 `public` 메서드를 사용해 클래스를 구현해 필요한 부분만 접근 가능하도록 의존 제한. + +현재까지 테스트 시나리오는 전부 통과하긴 했지만 과연 내 테스트 시나리오가 충분한지에 대한 의문이 남음. diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 76c070e..15cbd5e 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -23,25 +23,25 @@ - 쿼리 파라미터 - page (선택, 기본=0, 정수 >= 0): 페이지 번호 - size (선택, 기본=10): 페이지 크기 - - type (선택): 공연 유형 (MUSICAL, PLAY, CONCERT, OPERA, DANCE, CLASSICAL, ETC) - - rating (선택): 관람 등급 (ALL, AGE12, AGE15, AGE18) - - q (선택): 공연 제목 검색 키워드 - - from (선택, yyyy-MM-dd): 조회 시작일 - - to (선택, yyyy-MM-dd): 조회 종료일 - -> 공연 기간(performanceStartDate~performanceEndDate)이 [from, to] 구간과 겹치는 공연만 반환한다. - - 기간 필터링: 공연 기간 [performanceStartDate, performanceEndDate] 와 조회 기간 [from, to] 가 **겹치면** 포함한다. - - 경계 포함(폐구간): performanceStartDate <= to AND performanceEndDate >= from - - from만 지정 시: performanceEndDate >= from - - to만 지정 시: performanceStartDate <= to - - from > to 인 경우: 400 BAD_REQUEST - - - 정렬: - 1) performanceStartDate DESC - 2) title ASC (title이 unique이므로 마지막 정렬조건으로 충분) - - - 페이지네이션: - - page는 0-기반 인덱스다. - - hasNext = 존재성 여부만 확인 + - type (선택): 공연 유형 (MUSICAL, PLAY, CONCERT, OPERA, DANCE, CLASSICAL, ETC) + - rating (선택): 관람 등급 (ALL, AGE12, AGE15, AGE18) + - q (선택): 공연 제목 검색 키워드 + - from (선택, yyyy-MM-dd): 조회 시작일 + - to (선택, yyyy-MM-dd): 조회 종료일 + -> 공연 기간(performanceStartDate~performanceEndDate)이 [from, to] 구간과 겹치는 공연만 반환한다. + - 기간 필터링: 공연 기간 [performanceStartDate, performanceEndDate] 와 조회 기간 [from, to] 가 **겹치면** 포함한다. + - 경계 포함(폐구간): performanceStartDate <= to AND performanceEndDate >= from + - from만 지정 시: performanceEndDate >= from + - to만 지정 시: performanceStartDate <= to + - from > to 인 경우: 400 BAD_REQUEST + + - 정렬: + 1) performanceStartDate DESC + 2) title ASC (title이 unique이므로 마지막 정렬조건으로 충분) + + - 페이지네이션: + - page는 0-기반 인덱스다. + - hasNext = 존재성 여부만 확인 - curl 명령 예시 diff --git a/docs/specs/api/show_register.md b/docs/specs/api/show_register.md index 222edb4..726e477 100644 --- a/docs/specs/api/show_register.md +++ b/docs/specs/api/show_register.md @@ -1,63 +1,76 @@ ### 요청 -- 메서드: `POST` +- 메서드: POST - 경로: `/api/show` - 헤더 - ``` Content-Type: application/json Authorization: Bearer ``` - 본문 - ```json { - "hallId": 1, - "title": "인셉션", - "type": "MUSICAL", - "rating": "AGE12", - "synopsis": "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", - "posterUrl": "https://example.com/posters/inception.jpg", - "performanceStartDate": "2025-10-01", - "performanceEndDate": "2025-10-31" + "hallId": 1, + "title": "인셉션", + "type": "MUSICAL", + "rating": "AGE12", + "synopsis": "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", + "posterUrl": "https://example.com/posters/inception.jpg", + "performanceStartDate": "2025-10-01", + "performanceEndDate": "2025-10-31", + "currency": "KRW", + "ticketGrades": [ + { "name": "VIP", "basePrice": 180000, "quantity": 100 }, + { "name": "R", "basePrice": 150000, "quantity": 50 }, + { "name": "S", "basePrice": 120000, "quantity": 30 }, + { "name": "A", "basePrice": 90000, "quantity": 20 } + ] } ``` - -- curl 명령 예시 - +- curl 예시 ```bash - curl -i -X POST 'http://localhost:8080/api/show' \ - -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0MTIzNCIsInJvbGVzIjoiUk9MRV9BRE1JTiIsInVzZXJJZCI6InRlc3QxMjM0Iiwibmlja05hbWUiOiJ0ZXN0IiwiaWF0IjoxNzU3MzExNDc5LCJleHAiOjE3NTczMTIwNzl9.xhEkuZEF0gZlvyX_F2kiAMEMGw_C2ZtGL8PmzLxhZQW32A9hmr6M0nauYEejXOFrZAb3nMdU3jFLxuhDWDbE2g' \ - -H 'Content-Type: application/json' \ - -d '{ - "hallId": 1, - "title": "인셉션", - "type": "MUSICAL", - "rating": "AGE12", - "synopsis": "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", - "posterUrl": "https://example.com/posters/inception.jpg", - "performanceStartDate": "2025-10-01", - "performanceEndDate": "2025-10-31" - }' + curl -i -X POST 'http://localhost:8080/api/show' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{ + "hallId": 1, + "title": "인셉션", + "type": "MUSICAL", + "rating": "AGE12", + "synopsis": "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", + "posterUrl": "https://example.com/posters/inception.jpg", + "performanceStartDate": "2025-10-01", + "performanceEndDate": "2025-10-31", + "currency": "KRW", + "ticketGrades": [ + { "name": "VIP", "basePrice": 180000, "quantity": 100 }, + { "name": "R", "basePrice": 150000, "quantity": 50 }, + { "name": "S", "basePrice": 120000, "quantity": 30 }, + { "name": "A", "basePrice": 90000, "quantity": 20 } + ] + }' ``` +--- + ### 응답 - 상태코드: `200 OK` - 본문 - ```json { - "status": "SUCCESS", - "data": { - "showId": 1 - }, - "timestamp": "2025-09-10T12:34:56.789Z" + "status": "SUCCESS", + "data": { + "showId": 1 + }, + "timestamp": "2025-09-10T12:34:56.789Z" } ``` +--- + ### 테스트 - [x] 올바른 요청을 보내면 status가 SUCCESS이다 @@ -69,3 +82,8 @@ - [x] 중복된 제목의 공연을 등록하면 INTERNAL_SERVER_ERROR가 발생한다 - [x] 존재하지 않는 hallId를 보내면 NOT_FOUND 상태코드를 반환한다 - [x] 비ADMIN 토큰으로 요청하면 FORBIDDEN 상태코드를 반환한다 +- [x] ticketGrades가 비어있으면 BAD_REQUEST를 반환한다 +- [x] ticketGrade의 name이 중복인 경우 BAD_REQUEST를 반환한다 +- [x] ticketGrade의 basePrice가 양수가 아닌 경우 BAD_REQUEST를 반환한다 +- [x] quantity가 양수가 아닌 경우 BAD_REQUEST를 반환한다 +- [x] currency가 비어있거나 잘못된 값이면 BAD_REQUEST를 반환한다 diff --git a/docs/specs/domain.md b/docs/specs/domain.md index abc8976..570d4ed 100644 --- a/docs/specs/domain.md +++ b/docs/specs/domain.md @@ -7,7 +7,6 @@ - AR 내부 연관은 FK 사용 허용 - AR 간 연관은 "간접 참조(식별자)"만 사용(FK 불허, via XId) -- 결제 도메인 분리 없음: Reservation AR 내부에 Payment/PaymentAttempt/Refund 포함 - Show 공연 기간 명확화: performanceStartDate, performanceEndDate(또는 값객체 PerformanceWindow) 사용 --- @@ -32,16 +31,18 @@ _Aggregate Root_ - 공연 시작일(performanceStartDate, yyyy-MM-dd) - 공연 종료일(performanceEndDate, yyyy-MM-dd) - 공연 스케줄(schedules: List\) +- 통화(currency: KRW) +- 등급(grades: List\) #### 행위 -- `create(command: ShowCreateCommand)` -- `registerSchedule(hallId, startAt, endAt)` +- `create(command: ShowCreateCommand)`: 공연 생성 +- `registerSchedule(hallId, startAt, endAt)`: 공연에 스케줄 등록 #### 관련 타입 - `ShowCreateCommand` - - title, type, rating, synopsis, posterUrl, performanceStartDate, performanceEndDate + - title, type, rating, synopsis, posterUrl, performanceStartDate, performanceEndDate, currency, ticketGrades - `ShowRegisterRequest` / `ShowRegisterResponse` --- @@ -62,101 +63,62 @@ _Entity_ --- -### 캐스팅(Casting) - -_Entity_ - -- 회차별 배역과 출연자 매핑 - -#### 속성 - -- scheduleId(FK) -- 배역명(roleName) -- 출연자명(personName) - ---- - ### 홀(Hall) - 공연 시설 - #### 속성 - 이름(name) +- 등록자ID(registantId) --- -### 좌석(Seat) +### 구역(Section) _Entity_ -- 홀 내부의 개별 좌석 +- 홀 내부의 구역 + - ex) A관 #### 속성 - hallId(FK) -- 열(rowLabel) -- 번호(number) -- 시야등급(viewGrade: NORMAL, PARTIAL_VIEW, OBSTRUCTED) -- 접근성(accessibility: GENERAL, WHEELCHAIR, COMPANION) +- 이름(name) --- -### 좌석등급(TicketGrade) +### 좌석(Seat) _Entity_ -- 홀 단위 좌석 등급 +- 홀 내부의 개별 좌석 #### 속성 -- hallId(FK) -- 이름(name) +- sectionId(FK) +- 열(rowNumber) +- 번호(seatNumber) --- -## 가격표(SchedulePricing) - -_Aggregate Root_ - -- 회차와 좌석등급 조합에 따른 가격 관리 - -#### 속성 - -- scheduleId -- 통화(currency) -- 시작일(validFrom) -- 종료일(validTo) -- 가격정책(pricingPolicy) - -#### 행위 - -- `createFor(scheduleId, currency, validFrom, validTo)` -- `putPrice(ticketGradeId, amount)` -- `removePrice(ticketGradeId)` - -#### 관련 타입 - -- `CreateSchedulePricingCommand` -- `PutTicketPriceCommand` - ---- - -### 가격행(TicketPriceLine) +### 등급(Grade) _Entity_ -- 가격표의 라인 항목 +- 쇼 단위 좌석 등급(가격/수량 포함) #### 속성 -- schedulePricingId(FK) -- ticketGradeId -- 금액(amount) +- showId(FK) +- 이름(name) — 쇼 내 유니크 +- 기본가격(basePrice) +- 수량(quantity) --- + + ## 회원(Member) _Aggregate Root_ @@ -185,123 +147,19 @@ _Aggregate Root_ --- -## 예매(Reservation) - -_Aggregate Root_ - -- 좌석 보류, 확정, 환불 및 결제 관리 - -#### 속성 - -- memberId -- scheduleId -- seatId -- ticketGradeId -- 상태(status: HOLDING, CONFIRMED, REFUNDED, CANCELED) -- 예매일시(reservedAt) -- 홀드만료일시(holdExpiresAt) -- 결제금액(paidAmount) - -#### 행위 - -- `hold(memberId, scheduleId, seatId, ticketGradeId, ttl)` -- `readyPayment(merchantUid, totalAmount)` -- `confirmPaid(merchantUid, approvedAt)` -- `cancelBeforeConfirm()` -- `requestRefund(amount, reason)` - -#### 관련 타입 - -- `HoldReservationCommand` -- `ReadyPaymentCommand` -- `ConfirmPaidCommand` -- `RequestRefundCommand` - ---- - -### 결제(Payment) - -_Entity_ - -- Reservation에 종속되는 결제 정보 - -#### 속성 - -- reservationId(FK) -- 상점거래ID(merchantUid, UNIQUE) -- 총액(totalAmount) -- 상태(status: READY, PENDING, PAID, PARTIALLY_REFUNDED, REFUNDED, FAILED, CANCELED) -- 승인일시(approvedAt) - ---- - -### 결제시도(PaymentAttempt) - -_Entity_ - -- 결제 요청 및 승인/실패 내역 - -#### 속성 - -- paymentId(FK) -- 결제수단(method: CARD, ACCOUNT_TRANSFER, MOBILE, VIRTUAL_ACCOUNT, SIMPLE_PAY) -- 요청금액(requestedAmount) -- 상태(attemptStatus: INIT, REQUESTED, APPROVED, DECLINED, EXPIRED) -- PG거래ID(pgTransactionId, UNIQUE) -- 요청일시(requestedAt) -- 승인일시(approvedAt) -- 실패사유(failureReason) - ---- - -### 환불(Refund) - -_Entity_ - -- 환불 내역 - -#### 속성 - -- paymentId(FK) -- 환불금액(amount) -- 상태(refundStatus: REQUESTED, PENDING, COMPLETED, FAILED) -- 요청일시(requestedAt) -- 완료일시(completedAt) -- 사유(reason) -- PG환불거래ID(pgRefundTransactionId) - ```mermaid erDiagram -%% ======= Aggregates (FK only inside AR) ======= +%% ======= Aggregates ======= %% Show AR Show ||--o{ ShowSchedule : has - ShowSchedule ||--o{ Casting : has -%% UNIQUE(scheduleId, roleName) on Casting - -%% Hall AR (Hall 내부에 Seat/TicketGrade) + Show ||--o{ Grade: has +%% Hall AR Hall ||--o{ Section : has Section ||--o{ Seat : has - Seat ||--o{ TicketGrade : has - Hall ||--o{ TicketGrade : has - -%% Reservation AR (Payment/Attempt/Refund 내부 포함) - Reservation ||--|| Payment : has_one - Payment ||--o{ PaymentAttempt : has_many - Payment ||--o{ Refund : has_many -%% UNIQUE(scheduleId, seatId) on Reservation - -%% SchedulePricing AR (가격표) - SchedulePricing ||--o{ TicketPriceLine : has_many %% ======= Cross-AR indirect references (NO FK) ======= %% ShowSchedule .. Hall : via hallId -%% SchedulePricing .. ShowSchedule : via scheduleId -%% TicketPriceLine .. TicketGrade : via ticketGradeId -%% Reservation .. Member : via memberId -%% Reservation .. ShowSchedule : via scheduleId -%% Reservation .. Seat : via seatId -%% Reservation .. TicketGrade : via ticketGradeId %% ======= Entities ======= @@ -315,6 +173,7 @@ erDiagram string posterUrl date performanceStartDate date performanceEndDate + enum currency "KRW" } ShowSchedule { @@ -325,12 +184,12 @@ erDiagram int runtimeMinutes } - Casting { + Grade { BIGINT id PK - BIGINT scheduleId FK - string roleName - string personName - %% UNIQUE(schedule_id, role_name) + BIGINT showId FK + string name + int basePrice + int quantity } Hall { @@ -353,28 +212,6 @@ erDiagram enum accessibility "GENERAL|WHEELCHAIR|COMPANION" } - TicketGrade { - BIGINT id PK - BIGINT hallId FK - string name - } - - SchedulePricing { - BIGINT id PK - BIGINT scheduleId - string currency - date validFrom - date validTo - string pricingPolicy - } - - TicketPriceLine { - BIGINT id PK - BIGINT schedulePricingId FK - BIGINT ticketGradeId - decimal amount - } - Member { BIGINT id PK string nickName @@ -383,49 +220,4 @@ erDiagram string email string authorities } - - Reservation { - BIGINT id PK - BIGINT memberId - BIGINT scheduleId - BIGINT seatId - BIGINT ticketGradeId - decimal paidAmount - enum status "HOLDING|CONFIRMED|CANCELED|REFUNDED" - datetime reservedAt - datetime holdExpiresAt - %% UNIQUE(schedule_id, seat_id) - } - - Payment { - BIGINT id PK - BIGINT reservationId FK - string merchantUid UK - decimal totalAmount - enum status "READY|PENDING|PAID|PARTIALLY_REFUNDED|REFUNDED|FAILED|CANCELED" - datetime approvedAt - } - - PaymentAttempt { - BIGINT id PK - BIGINT paymentId FK - enum method "CARD|ACCOUNT_TRANSFER|MOBILE|VIRTUAL_ACCOUNT|SIMPLE_PAY" - decimal requestedAmount - enum attemptStatus "INIT|REQUESTED|APPROVED|DECLINED|EXPIRED" - string pgTransactionId UK - datetime requestedAt - datetime approvedAt - string failureReason - } - - Refund { - BIGINT id PK - BIGINT paymentId FK - decimal amount - enum refundStatus "REQUESTED|PENDING|COMPLETED|FAILED" - datetime requestedAt - datetime completedAt - string reason - string pgRefundTransactionId - } ``` diff --git a/docs/specs/policy/application.md b/docs/specs/policy/application.md index 89eed0b..4b02406 100644 --- a/docs/specs/policy/application.md +++ b/docs/specs/policy/application.md @@ -70,8 +70,8 @@ - application -> domain (OK), adapter (금지) - adapter -> application 포트(OK), application 서비스/구현(금지), domain(읽기 전용 OK. 단, 비즈니스 수행은 application 경유) - DTO/엔티티 경계: - - webapi의 요청/응답 DTO는 한시적으로 domain에 존재. 추후 변경 가능성 있음. - - 영속성 엔티티는 domain에만 존재. domain 엔티티와 동일 클래스로 사용. + - webapi의 요청/응답 DTO는 한시적으로 domain에 존재. 추후 변경 가능성 있음. + - 영속성 엔티티는 domain에만 존재. domain 엔티티와 동일 클래스로 사용. ## 3. 포트와 어댑터 @@ -90,12 +90,12 @@ - 트랜잭션 경계: application 계층의 유스케이스 서비스 메서드 수준에서 관리(`@Transactional`). 컨트롤러/어댑터에서는 트랜잭션을 시작하지 않습니다. - 검증: - - 형태/구문 검증: adapter(webapi)에서 기본적인 바인딩/형식 검증 허용. - - 비즈니스/정책 검증: application 또는 domain에서 수행. `Validator` 등의 컴포넌트는 application에 위치. + - 형태/구문 검증: adapter(webapi)에서 기본적인 바인딩/형식 검증 허용. + - 비즈니스/정책 검증: application 또는 domain에서 수행. `Validator` 등의 컴포넌트는 application에 위치. - 예외: - - 도메인 오류는 domain 예외(`DomainException`의 자식 클래스)로 표현. - - 어댑터/기술 오류는 해당 계층에서 포착하고 domain 의미의 예외로 변환 또는 적절히 매핑. - - webapi는 예외를 `GlobalExceptionHandler`로 공통 변환하여 `ErrorResponse`로 응답. + - 도메인 오류는 domain 예외(`DomainException`의 자식 클래스)로 표현. + - 어댑터/기술 오류는 해당 계층에서 포착하고 domain 의미의 예외로 변환 또는 적절히 매핑. + - webapi는 예외를 `GlobalExceptionHandler`로 공통 변환하여 `ErrorResponse`로 응답. - 로깅: 크로스커팅은 internal 계층의 AOP(`LoggingAspect`)에서 처리. 민감 정보(비밀번호, 토큰 등)는 로그 금지. ## 5. 모듈 구조 규칙 diff --git a/docs/specs/policy/test.md b/docs/specs/policy/test.md index b1d0fc3..741d674 100644 --- a/docs/specs/policy/test.md +++ b/docs/specs/policy/test.md @@ -66,29 +66,29 @@ - 각 테스트는 `IntegrationTestUtils`를 사용해 작성 - `IntegrationTestUtils` 사용 방법은 다음과 같음 - ```java - @Test - void withoutAuth(@Autowired IntegrationTestUtils testUtils) { - // Act & Assert - var response = testUtils.get("/test/without-auth") - .assertSuccess(String.class); - + @Test + void withoutAuth(@Autowired IntegrationTestUtils testUtils) { + // Act & Assert + var response = testUtils.get("/test/without-auth") + .assertSuccess(String.class); + assertThat(response.getData()).isEqualTo(PONG_WITHOUT_AUTH); - } - ``` - - ```java - @Test - void failToAuth(@Autowired IntegrationTestUtils testUtils) { - // Arrange - var invalidToken = "invalid token"; - - // Act & Assert - var response = testUtils.get("/test/with-auth") - .withAuthorization(invalidToken) - .assertFailure(); - assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); - assertThat(response.getData()).isEqualTo("유효한 토큰이 없습니다."); - } + } ``` + - ```java + @Test + void failToAuth(@Autowired IntegrationTestUtils testUtils) { + // Arrange + var invalidToken = "invalid token"; + + // Act & Assert + var response = testUtils.get("/test/with-auth") + .withAuthorization(invalidToken) + .assertFailure(); + assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); + assertThat(response.getData()).isEqualTo("유효한 토큰이 없습니다."); + } + ``` - 데이터 초기화는 테스트 메서드 단위로 독립되게 유지. H2 메모리 DB가 매 테스트 클래스/메서드 기준으로 깨끗한 상태를 갖도록 설계한다. - 인증이 필요한 엔드포인트는 `JwtTestUtils`로 유효 토큰을 발급하여 헤더 `Authorization: Bearer `를 부착. - 예외/에러 응답은 `GlobalExceptionHandler` 정책에 맞춰 상태코드/본문을 검증. @@ -177,8 +177,8 @@ - 단위 테스트: 대상 패키지에 맞춰 배치, 클래스명 `*Test` - 통합 테스트: 시나리오 중심 폴더 구조 사용 가능 - 예시) - - POST `/api/auth/login`: `application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java` - - GET `/api/show`: `application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java` + - POST `/api/auth/login`: `application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java` + - GET `/api/show`: `application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java` - 아키텍처 테스트: `arch/*` --- diff --git a/docs/todo.md b/docs/todo.md index c48b074..25b88c7 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -28,10 +28,13 @@ - [x] Spring Modulith 사용 가능한지 점검 2025.09.23 -- [ ] hall register - - [ ] register with registant name - - [ ] register with seats ---- +- [x] hall register + - [x] register with registant name + - [x] register with seats + +2025.10.02 -- [ ] show register에 grade 추가 고민 +- [x] show register에 grade 추가 고민 + +--- diff --git a/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java b/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java index c6754ea..54af983 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java @@ -14,7 +14,7 @@ Class> value(); - String message() default "invalid value, must be one from valid enum types"; + String message() default "invalid value, must be one of valid enum types"; Class[] groups() default {}; 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 new file mode 100644 index 0000000..9e3ed09 --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/show/Grade.java @@ -0,0 +1,38 @@ +package org.mandarin.booking.domain.show; + +import static jakarta.persistence.FetchType.LAZY; +import static lombok.AccessLevel.PACKAGE; +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.mandarin.booking.domain.AbstractEntity; +import org.mandarin.booking.domain.show.ShowRegisterRequest.GradeRequest; + +@Entity +@Getter(value = PROTECTED) +@NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor(access = PACKAGE) +@Table(uniqueConstraints = @UniqueConstraint(name = "uk_grade_show_name", columnNames = {"show_id", "name"})) +class Grade extends AbstractEntity { + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "show_id", nullable = false) + private Show show; + + private String name; + + private Integer basePrice; + + private Integer quantity; + + static Grade of(Show show, GradeRequest gradeRequest) { + return new Grade(show, gradeRequest.name(), gradeRequest.basePrice(), gradeRequest.quantity()); + } +} 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 af5d098..b6f7064 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 @@ -16,8 +16,10 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.mandarin.booking.Currency; import org.mandarin.booking.domain.AbstractEntity; import org.mandarin.booking.domain.show.ShowDetailResponse.ShowScheduleResponse; +import org.mandarin.booking.domain.show.ShowRegisterRequest.GradeRequest; @Entity @Table(name = "shows") @@ -45,9 +47,16 @@ public class Show extends AbstractEntity { private LocalDate performanceEndDate; + @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, - LocalDate performanceEndDate) { + LocalDate performanceEndDate, + Currency currency) { this.hallId = hallId; this.title = title; this.type = type; @@ -56,6 +65,7 @@ private Show(Long hallId, String title, Type type, Rating rating, String synopsi this.posterUrl = posterUrl; this.performanceStartDate = performanceStartDate; this.performanceEndDate = performanceEndDate; + this.currency = currency; } public void registerSchedule(ShowScheduleCreateCommand command) { @@ -88,7 +98,7 @@ public static Show create(Long hallId, ShowCreateCommand command) { throw new ShowException("공연 시작 날짜는 종료 날짜 이후에 있을 수 없습니다."); } - return new Show( + var show = new Show( hallId, command.getTitle(), command.getType(), @@ -96,8 +106,19 @@ public static Show create(Long hallId, ShowCreateCommand command) { command.getSynopsis(), command.getPosterUrl(), startDate, - endDate + 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); } private boolean isInSchedule(LocalDateTime scheduleStartAt, LocalDateTime scheduleEndAt) { @@ -122,9 +143,12 @@ public static class ShowCreateCommand { private final String posterUrl; private final LocalDate performanceStartDate; private final LocalDate performanceEndDate; + private final Currency currency; + private final List ticketGrades; private ShowCreateCommand(String title, Type type, Rating rating, String synopsis, String posterUrl, - LocalDate performanceStartDate, LocalDate performanceEndDate) { + LocalDate performanceStartDate, LocalDate performanceEndDate, + Currency currency, List ticketGrades) { this.title = title; this.type = type; this.rating = rating; @@ -132,9 +156,12 @@ private ShowCreateCommand(String title, Type type, Rating rating, String synopsi this.posterUrl = posterUrl; this.performanceStartDate = performanceStartDate; this.performanceEndDate = performanceEndDate; + this.currency = currency; + this.ticketGrades = ticketGrades; } - public static ShowCreateCommand from(ShowRegisterRequest request) { + public static ShowCreateCommand from( + ShowRegisterRequest request) {//TODO 2025 10 03 00:26:26 : test 코드를 위해 public...? return new ShowCreateCommand( request.title(), Type.valueOf(request.type()), @@ -142,7 +169,9 @@ public static ShowCreateCommand from(ShowRegisterRequest request) { request.synopsis(), request.posterUrl(), request.performanceStartDate(), - request.performanceEndDate() + request.performanceEndDate(), + Currency.valueOf(request.currency()), + request.ticketGrades() ); } } diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java index 08d6753..244c51d 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java @@ -1,9 +1,17 @@ package org.mandarin.booking.domain.show; +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.FutureOrPresent; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.mandarin.booking.Currency; import org.mandarin.booking.domain.EnumRequest; import org.mandarin.booking.domain.show.Show.Rating; import org.mandarin.booking.domain.show.Show.Type; @@ -34,7 +42,31 @@ public record ShowRegisterRequest( LocalDate performanceStartDate, @NotNull(message = "performance end date is required") - LocalDate performanceEndDate + LocalDate performanceEndDate, + + @NotBlank(message = "currency is required") + @EnumRequest(value = Currency.class, message = "invalid currency") + String currency, + + @NotNull(message = "ticketGrades are required") + @NotEmpty(message = "ticketGrades must not be empty") + List<@Valid GradeRequest> ticketGrades ) { -} + @AssertTrue(message = "ticketGrade names must be unique") + public boolean hasUniqueTicketGradeNames() { + Set names = ticketGrades.stream().map(GradeRequest::name).collect(Collectors.toSet()); + return names.size() == ticketGrades.size(); + } + public record GradeRequest( + @NotBlank(message = "ticketGrade name is required") + String name, + + @Positive(message = "basePrice must be positive") + Integer basePrice, + + @Positive(message = "quantity must be positive") + Integer quantity + ) { + } +}