diff --git a/AGENTS.md b/AGENTS.md index c470d45..b270c9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,8 +55,8 @@ Generated by IntelliJ AI based on repository scan (2025-08-31 16:52) - DTO(요청/응답/커맨드)는 현재 domain에 위치하며 컨트롤러에서 변환하여 사용 (근거: policy 문서). - 영속성(JPA) - 의존성: `spring-boot-starter-data-jpa`, DB: MySQL(H2 for test), P6Spy (근거: `build.gradle`) - - 저장소 규약: Spring Data Repository 인터페이스는 `app/persist`에 위치 (예: `MemberRepository`, `MovieRepository`). - - 트랜잭션 경계는 app 서비스 (예: `MovieCommandRepository`에 `@Transactional`). + - 저장소 규약: Spring Data Repository 인터페이스는 `app/persist`에 위치 (예: `MemberRepository`, `ShowRepository`). + - 트랜잭션 경계는 app 서비스 (예: `ShowCommandRepository`에 `@Transactional`). - 보안(Spring Security/JWT) - JWT 파싱/인증: `adapter/security/JwtFilter`가 Authorization 헤더 `Bearer ` 처리 (근거 파일). - SecurityFilterChain 설정과 우선순위: `SecurityConfig` @@ -73,8 +73,9 @@ Generated by IntelliJ AI based on repository scan (2025-08-31 16:52) 에이전트가 코드를 작성할 때 반드시 준수해야 하는 가독성 중심 규칙입니다. 5.1 네이밍 -- 클래스/인터페이스: 명확한 도메인 언어 사용 (예: MovieRegisterer, PaymentGatewayClient) -- 메서드: 동사+목적어 (예: registerMovie, validateToken, findMemberByEmail) + +- 클래스/인터페이스: 명확한 도메인 언어 사용 (예: ShowRegisterer, PaymentGatewayClient) +- 메서드: 동사+목적어 (예: registerShow, validateToken, findMemberByEmail) - 변수/필드: 축약 금지, 문맥 명확히 (예: memberEmail, jwtTokenSecret) - 테스트 메서드: 시나리오 기반 네이밍 (예: shouldFailWhenPasswordIsInvalid) @@ -198,7 +199,7 @@ docker compose up -d - 파일/이름 규칙 - API 스펙: `docs/specs/api/_.md` 또는 엔드포인트 의미가 드러나는 snake_case 파일명 사용 - - 근거: `docs/specs/api/login.md`, `member_register.md`, `movie_register.md`, `reissue.md` + - 근거: `docs/specs/api/login.md`, `member_register.md`, `show_register.md`, `reissue.md` - 정책 문서: `docs/specs/policy/.md` - 근거: `docs/specs/policy/application.md`, `authentication.md`, `authorization.md`, `test.md` - 도메인 설계: `docs/specs/domain.md` @@ -208,7 +209,7 @@ docker compose up -d 2) 요청 섹션 구성 - 메서드, 경로, 헤더 코드블록, 본문 JSON 예시(필수 필드 포맷 포함) - 실행 가능한 curl 예시를 제공 - - 근거 예시: `docs/specs/api/login.md`, `member_register.md`, `movie_register.md`, `reissue.md` + - 근거 예시: `docs/specs/api/login.md`, `member_register.md`, `show_register.md`, `reissue.md` 3) 응답 섹션 구성 - 상태코드 명시, 응답 JSON 예시 제공(필수 필드 포함) 4) 테스트 섹션 구성 @@ -253,7 +254,7 @@ docker compose up -d - 실행 불가한 모호한 명령 예시 금지. 검증되지 않은 외부 의존성 언급 금지. - 예시 스니펫 스타일 - - curl: 실제 경로/헤더/본문 포함(예: `docs/specs/api/movie_register.md`의 curl 예시) + - curl: 실제 경로/헤더/본문 포함(예: `docs/specs/api/show_register.md`의 curl 예시) - JSON: 축약하지 말고 필요한 필드를 명시, 포맷은 pretty 또는 단일 라인 일관 유지. --- diff --git a/README.md b/README.md index 8755d68..c4bde25 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ -# booking — 영화 예매 시스템 (개요 중심 README) - -이 문서는 프로젝트를 어떻게 실행하는지보다는, 이 프로젝트가 무엇인지, 어떤 구성과 아키텍처를 가지는지, 어떤 기술을 왜 선택했는지, 그리고 어떤 방식으로 개발하는지를 설명합니다. 모든 주장은 저장소 내 문서/코드에 대한 링크로 근거를 제시합니다. +# booking — 공연 예매 시스템 (개요 중심 README) - 저장소 루트: 단일 모듈(Spring Boot) 프로젝트 --- ## 1. 프로젝트 소개 -영화 예매(Booking) 도메인을 다루는 학습 목적의 Spring Boot 애플리케이션입니다. 헥사고날 아키텍처(Ports & Adapters)를 채택하여 도메인 규칙을 프레임워크로부터 분리하고, 보안/웹/영속성 같은 어댑터 계층을 통해 외부 세계와 상호작용합니다. + +공연 예매(Booking) 도메인을 다루는 학습 목적의 Spring Boot 애플리케이션입니다. 헥사고날 아키텍처(Ports & Adapters)를 채택하여 도메인 규칙을 프레임워크로부터 분리하고, 보안/웹/영속성 +같은 어댑터 계층을 통해 외부 세계와 상호작용합니다. - 도메인 개요: [docs/specs/domain.md](docs/specs/domain.md) - 아키텍처/개발 규칙: [docs/specs/policy/application.md](docs/specs/policy/application.md) @@ -41,7 +41,8 @@ --- ## 4. 도메인 모델 요약 -- Movie (Aggregate Root): 제목, 감독, 장르, 상영시간, 개봉일, 등급, 줄거리, 포스터URL, 출연진 등. 팩토리/커맨드 기반 생성. + +- Show (Aggregate Root): 제목, 감독, 장르, 상영시간, 개봉일, 등급, 줄거리, 포스터URL, 출연진 등. 팩토리/커맨드 기반 생성. - Member (Aggregate Root): 닉네임, userId, email, passwordHash, 권한 목록. 비밀번호 해시 일치 검증. 자세한 속성과 규칙: [docs/specs/domain.md](docs/specs/domain.md) @@ -73,8 +74,7 @@ Build/Test 구성 근거: `build.gradle`의 `tasks.named('test')` 설정(Profile ## 7. 보안 개요 - 필터 기반 JWT 인증: `JwtFilter`가 Authorization `Bearer `을 파싱해 SecurityContext 설정. - 경로별 권한: `SecurityConfig`의 `@Order(1) apiChain` - - 공개: `POST /api/member`, `/api/auth/login`, `/api/auth/reissue` - - 권한 필요: `POST /api/movie`는 `ROLE_DISTRIBUTOR` + - 차후 추가 작성 - 예외 처리: `CustomAuthenticationEntryPoint`, `CustomAccessDeniedHandler` 근거: `src/main/java/org/mandarin/booking/adapter/security/*` @@ -97,7 +97,7 @@ Build/Test 구성 근거: `build.gradle`의 `tasks.named('test')` 설정(Profile - 로그인: [docs/specs/api/login.md](docs/specs/api/login.md) - 회원 가입: [docs/specs/api/member_register.md](docs/specs/api/member_register.md) - 토큰 재발급: [docs/specs/api/reissue.md](docs/specs/api/reissue.md) -- 영화 등록: [docs/specs/api/movie_register.md](docs/specs/api/movie_register.md) +- 공연 등록: [docs/specs/api/show_register.md](docs/specs/api/show_register.md) 각 문서 하단의 테스트 체크리스트가 수용 기준입니다. diff --git a/docs/devlog/250904.md b/docs/devlog/250904.md new file mode 100644 index 0000000..dc3e0ff --- /dev/null +++ b/docs/devlog/250904.md @@ -0,0 +1,6 @@ +## 예찬 + +페어 프로그래밍에서 싱글 프로젝트로 전환됐다. 이에 따라서 요구사항을 혼자 제어 가능한 수준으로 좁히고 도메인 영역의 복잡성을 최소화 하기 위해 다양한 추천 시스템이 필요한 영화 예매 서비스 보다는 공연 예매 +서비스로 프로젝트를 진행하는것이 추후 도메인 요구사항을 빠르게 쳐내는데에 적합하다고 판단되어 도메인 전환을 진행했다. + +기존 Movie 도메인이 Show로 전환된 부분이랑 기타 일부 자잘구리한 변경사항을 제외한다면 아직까지는 큰 변화는 없다. 이 부분에 대해 [도메인 명세서](../specs/domain.md)에 나름 적어봤다. diff --git a/docs/specs/api/movie_register.md b/docs/specs/api/show_register.md similarity index 90% rename from docs/specs/api/movie_register.md rename to docs/specs/api/show_register.md index 1778998..da21903 100644 --- a/docs/specs/api/movie_register.md +++ b/docs/specs/api/show_register.md @@ -1,7 +1,7 @@ ### 요청 - 메서드: `POST` -- 경로: `/api/movies` +- 경로: `/api/show` - 헤더 ``` @@ -19,7 +19,7 @@ - curl 명령 예시 ```bash - curl -i -X POST 'http://localhost:8080/api/movie' \ + curl -i -X POST 'http://localhost:8080/api/show' \ -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0MTIzNCIsInJvbGVzIjoiUk9MRV9ESVNUUklCVVRPUiIsInVzZXJJZCI6InRlc3QxMjM0Iiwibmlja05hbWUiOiJ0ZXN0IiwiaWF0IjoxNzU2NDM4MjIzLCJleHAiOjE3NTY0Mzg4MjN9.DN0wZb8BdKY-7Grd0KAALXf88KX3iF_tg6UmcfotkFOlbRoRnSuY1nNVUFfZk2TxP0hvju3A8AglK3mt_hnutQ' \ -H 'Content-Type: application/json' \ -d '{ @@ -48,7 +48,7 @@ { "status": "SUCCESS", "data": { - "movieId": 1 + "showId": 1 }, "timestamp": "2024-06-10T12:34:56.789Z" } @@ -57,7 +57,7 @@ ### 테스트 - [x] 올바른 요청을 보내면 status가 SUCCESS이다 -- [ ] 올바른 요청을 보내면 응답 본문에 movieId가 존재한다 +- [ ] 올바른 요청을 보내면 응답 본문에 showId가 존재한다 - [x] Authorization 헤더에 유효한 accessToken이 없으면 status가 UNAUTHORIZED이다 - [x] title, director, runtimeMinutes, genre, releaseDate, rating이 비어있으면 BAD_REQUEST이다 - [x] runtimeMinutes은 0 미만이면 BAD_REQUEST이다 diff --git a/docs/specs/domain.md b/docs/specs/domain.md index 57407b1..e32d100 100644 --- a/docs/specs/domain.md +++ b/docs/specs/domain.md @@ -1,85 +1,406 @@ -# 영화 예매 시스템 도메인 설계 - ---- +# 공연 예매 시스템 도메인 설계 ## 개요 -이 문서는 영화 예매 시스템의 도메인 모델을 설계하기 위한 것입니다. -## 도메인 모델 +이 문서는 공연 예매 시스템의 도메인 모델 설계 및 주요 유스케이스 흐름을 설명합니다. +설계는 도메인 모델 패턴을 따르며, 다음 원칙을 강제합니다. +- AR 내부 연관은 FK 사용 허용 +- AR 간 연관은 "간접 참조(식별자)"만 사용(FK 불허, via XId) +- 결제 도메인 분리 없음: Reservation AR 내부에 Payment/PaymentAttempt/Refund 포함 +- Show 공연 기간 명확화: performanceStartDate, performanceEndDate(또는 값객체 PerformanceWindow) 사용 + +--- +# 공연 예매 시스템 도메인 설계 -### 영화(Movie) +--- + +## 공연(Show) _Aggregate Root_ -- 상영될 콘텐츠 자체. +- 공연 작품 자체 #### 속성 - 제목(title) -- 감독(director) -- 상영시간(runtimeMinutes, 분) -- 장르(genre: ACTION/DRAMA/COMEDY/THRILLER/ROMANCE/SF/FANTASY/HORROR/ANIMATION/DOCUMENTARY/ETC) -- 관람등급(rating: ALL/AGE12/AGE15/AGE18) -- 개봉일(releaseDate, yyyy-MM-dd) +- 유형(type: MUSICAL, PLAY, CONCERT, OPERA, DANCE, CLASSICAL, ETC) +- 관람등급(rating: ALL, AGE12, AGE15, AGE18) - 줄거리(synopsis) - 포스터 URL(posterUrl) -- 출연 배우 목록(casts: Set) +- 공연 시작일(performanceStartDate, yyyy-MM-dd) +- 공연 종료일(performanceEndDate, yyyy-MM-dd) #### 행위 -- `create(command: MovieCreateCommand)`: 커맨드로부터 영화를 생성합니다. +- `create(command: ShowCreateCommand)` +- `addSchedule(hallId, startAt, endAt, runtimeOverride)` +- `setCasting(scheduleId, roleName, personName)` +- `changePerformanceWindow(start, end)` #### 관련 타입 -- `MovieCreateCommand`: 영화 생성 커맨드 - - title, genre, runtimeMinutes, director, synopsis, posterUrl, releaseDate, rating, casts(Set) -- `MovieRegisterRequest` / `MovieRegisterResponse`: 웹 API 요청/응답 DTO +- `ShowCreateCommand` + - title, type, rating, synopsis, posterUrl, performanceStartDate, performanceEndDate +- `ShowRegisterRequest` / `ShowRegisterResponse` --- -### 사용자(Member) +### 회차(ShowSchedule) +_Entity_ +- 특정 공연이 특정 홀에서 특정 시간에 진행되는 스케줄 + +#### 속성 +- showId(FK) +- hallId +- 시작일시(startAt) +- 종료일시(endAt) +- 상영시간(runtimeMinutes) + +--- + +### 캐스팅(Casting) +_Entity_ +- 회차별 배역과 출연자 매핑 + +#### 속성 +- scheduleId(FK) +- 배역명(roleName) +- 출연자명(personName) + +--- + +## 공연장(Venue) _Aggregate Root_ -- 서비스를 사용하는 사람(회원). +- 공연 시설 #### 속성 -- 닉네임(nickName) -- 아이디(userId) — unique -- 비밀번호 해시(passwordHash) -- 이메일(email) -- 권한(authorities: List) — 기본값 USER +- 이름(name) +- 주소(address) #### 행위 -- `create(command: MemberCreateCommand, encoder: SecurePasswordEncoder)`: 암호화된 비밀번호로 회원을 생성합니다. -- `matchesPassword(rawPassword, encoder)`: 주어진 평문 비밀번호가 저장된 해시와 일치하는지 확인합니다. +- `addHall(name)` +- `addSeat(hallId, rowLabel, number, viewGrade, accessibility)` +- `defineGrade(hallId, name)` #### 관련 타입 -- `MemberCreateCommand` (inner record of Member): nickName, userId, password(평문), email -- `MemberRegisterRequest` / `MemberRegisterResponse`: 웹 API 요청/응답 DTO -- `MemberAuthority`: USER/DISTRIBUTOR/ADMIN 권한 정의, 컨버터를 사용해 문자열 영속화, 추가적인 테이블 생성 방지 +- `CreateVenueCommand` +- `AddHallCommand` +- `AddSeatCommand` +- `DefineGradeCommand` --- -### 영화관(Cinema) -_Aggregate Root_ -- 영화 상영 시설. +### 홀(Hall) +_Entity_ +- 공연장 내부의 개별 공간 + +#### 속성 +- venueId(FK) +- 이름(name) --- -### 상영관(ScreeningRoom) +### 좌석(Seat) _Entity_ -- 영화관 내에서 실제로 영화가 상영되는 개별 공간. +- 홀 내부의 개별 좌석 + +#### 속성 +- hallId(FK) +- 열(rowLabel) +- 번호(number) +- 시야등급(viewGrade: NORMAL, PARTIAL_VIEW, OBSTRUCTED) +- 접근성(accessibility: GENERAL, WHEELCHAIR, COMPANION) --- -### 상영정보(ScreeningSchedule) +### 좌석등급(TicketGrade) _Entity_ -- 특정 영화가 특정 상영관에서 특정 날짜와 시간에 상영되는 스케줄. +- 홀 단위 좌석 등급 + +#### 속성 +- hallId(FK) +- 이름(name) --- -### 좌석(Seat) + +## 가격표(SchedulePricing) +_Aggregate Root_ +- 회차와 좌석등급 조합에 따른 가격 관리 + +#### 속성 +- scheduleId +- 통화(currency) +- 시작일(validFrom) +- 종료일(validTo) +- 가격정책(pricingPolicy) + +#### 행위 +- `createFor(scheduleId, currency, validFrom, validTo)` +- `putPrice(ticketGradeId, amount)` +- `removePrice(ticketGradeId)` + +#### 관련 타입 +- `CreateSchedulePricingCommand` +- `PutTicketPriceCommand` + +--- + +### 가격행(TicketPriceLine) _Entity_ -- 상영관 내의 개별 의자. +- 가격표의 라인 항목 + +#### 속성 +- schedulePricingId(FK) +- ticketGradeId +- 금액(amount) --- -### 예매(Reservation) +## 회원(Member) _Aggregate Root_ -- 사용자가 특정 상영정보의 특정 좌석의 구매를 확정한 기록. +- 서비스를 사용하는 회원 +#### 속성 +- 닉네임(nickName) +- 아이디(userId, UNIQUE) +- 비밀번호(passwordHash) +- 이메일(email) +- 권한(authorities: USER, DISTRIBUTOR, ADMIN) + +#### 행위 +- `register(command: MemberRegisterCommand, encoder)` +- `changeNickName(newNickName)` +- `changeEmail(newEmail)` +- `matchesPassword(rawPassword, encoder)` + +#### 관련 타입 +- `MemberRegisterCommand` +- `MemberRegisterRequest` / `MemberRegisterResponse` + +--- + +## 예매(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) ======= + +%% Show AR + Show ||--o{ ShowSchedule : has + ShowSchedule ||--o{ Casting : has +%% UNIQUE(scheduleId, roleName) on Casting + +%% Venue AR (Venue 내부에 Hall/Seat/TicketGrade) + Venue ||--o{ Hall : has + Hall ||--o{ Seat : 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 ======= + + Show { + BIGINT id PK + string title + enum type "MUSICAL|PLAY|CONCERT|OPERA|DANCE|CLASSICAL|ETC" + enum rating "ALL|AGE12|AGE15|AGE18" + text synopsis + string posterUrl + date performanceStartDate + date performanceEndDate + } + + ShowSchedule { + BIGINT id PK + BIGINT showId FK + BIGINT hallId + datetime startAt + datetime endAt + int runtimeMinutes + } + + Casting { + BIGINT id PK + BIGINT scheduleId FK + string roleName + string personName + %% UNIQUE(schedule_id, role_name) + } + + Venue { + BIGINT id PK + string name + string address + } + + Hall { + BIGINT id PK + BIGINT venueId FK + string name + } + + Seat { + BIGINT id PK + BIGINT hallId FK + string rowLabel + int number + enum viewGrade "NORMAL|PARTIAL_VIEW|OBSTRUCTED" + 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 + string userId UK + string passwordHash + 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 7a3cac6..62a1b93 100644 --- a/docs/specs/policy/application.md +++ b/docs/specs/policy/application.md @@ -45,7 +45,7 @@ 권장 네이밍: - 입력 포트: UseCase 동사형 + er (예: Registerer, UseCase) -- 출력 포트: 리소스 + 동작 + Repository/Gateway (예: MovieCommandRepository) +- 출력 포트: 리소스 + 동작 + Repository/Gateway (예: ShowCommandRepository) - 그 외에는 해당 인터페이스가 담당한 기능의 추상적 개념을 나타내는 네이밍 ## 4. 트랜잭션/검증/예외/로깅 규칙 @@ -63,7 +63,7 @@ ## 5. 패키지 구조 규칙 - domain: `org.mandarin.booking.domain.{boundedContext}` - - 예: `domain.member`, `domain.movie` + - 예: `domain.member`, `domain.show` - app: `org.mandarin.booking.app` - 하위: `port`, `persist`(출력 포트/구현), 서비스 클래스 - adapter: `org.mandarin.booking.adapter.{webapi|security|...}` @@ -76,10 +76,11 @@ - 유스케이스 반환값 -> web DTO로 매핑하여 응답한다. - 컨트롤러에서 비즈니스 로직/트랜잭션 처리 금지. -예시(영화 등록): -- `adapter/webapi/MovieController` -> `app/port/MovieRegisterer` 호출 -- `domain.movie.MovieRegisterRequest`/`MovieCreateCommand` 사용하여 유스케이스 실행 -- 결과를 `domain.movie.MovieRegisterResponse` 받아 web 응답으로 래핑(`ApiResponse`) +예시(공연 등록): + +- `adapter/webapi/ShowController` -> `app/port/ShowRegisterer` 호출 +- `domain.show.ShowRegisterRequest`/`ShowCreateCommand` 사용하여 유스케이스 실행 +- 결과를 `domain.show.ShowRegisterResponse` 받아 web 응답으로 래핑(`ApiResponse`) ## 7. 영속성 규칙(JPA) @@ -103,17 +104,18 @@ ## 10. 확장 가이드(새 유스케이스/어댑터 추가) -새 유스케이스(예: 영화 수정) 추가 절차: -1) domain에 필요한 모델/명세 정의(예: `MovieUpdateCommand`). -2) app/port에 입력 포트 정의(예: `MovieUpdater`). -3) app에 서비스 구현(`MovieService` 내 메서드 또는 별도 서비스) 및 트랜잭션/검증 구현. +새 유스케이스(예: 공연 수정) 추가 절차: + +1) domain에 필요한 모델/명세 정의(예: `ShowUpdateCommand`). +2) app/port에 입력 포트 정의(예: `ShowUpdater`). +3) app에 서비스 구현(`ShowService` 내 메서드 또는 별도 서비스) 및 트랜잭션/검증 구현. 4) 필요 시 출력 포트 정의 및 어댑터 구현(persist/JPA 등). 5) adapter/webapi에 컨트롤러 엔드포인트 추가 및 DTO 매핑. 6) 아키텍처/통합 테스트 통과 확인. 새 어댑터(예: 외부 결제 API) 추가 절차: 1) app에 출력 포트 인터페이스 추가(예: `PaymentGateway`). -2) adapter 하위에 구현(예: `adapter/payment/PaymentGatewayHttpClient`). +2) adapter 하위에 구현(예: `adapter/external/PaymentGatewayHttpClient`). 3) 구성(Security/Config)과 예외 매핑 추가. ## 11. 공통 규칙 요약(Do/Don’t) @@ -131,7 +133,7 @@ Don’t ## 12. 용어 -- 도메인 모델: 비즈니스 개념을 표현하는 순수 객체(`Member`, `Movie` 등) +- 도메인 모델: 비즈니스 개념을 표현하는 순수 객체(`Member`, `Show` 등) - 유스케이스: 시스템이 제공하는 기능 단위(등록, 로그인 등) - 포트: 유스케이스(입력) 또는 외부 의존(출력)을 추상화한 인터페이스 - 어댑터: 포트를 구현하여 외부 세계와 연결하는 기술 계층 diff --git a/docs/specs/policy/authorization.md b/docs/specs/policy/authorization.md index f2147b9..120471b 100644 --- a/docs/specs/policy/authorization.md +++ b/docs/specs/policy/authorization.md @@ -25,7 +25,7 @@ - `POST /api/auth/reissue` - 권한 필요(hasAuthority): - - `POST /api/movie` → `ROLE_DISTRIBUTOR` + - `POST /api/show` → `ROLE_DISTRIBUTOR` - 그 외 `/api/**`: - `anyRequest().authenticated()` → 유효한 JWT 필요(특정 권한 제한 없음). 컨트롤러/도메인 단에서 별도 검증이 필요한 경우 추가 로직으로 보완. @@ -38,7 +38,7 @@ - `.requestMatchers(HttpMethod.POST, "/api/member").permitAll()` - `.requestMatchers("/api/auth/login").permitAll()` - `.requestMatchers("/api/auth/reissue").permitAll()` -- `.requestMatchers(HttpMethod.POST, "/api/movie").hasAuthority("ROLE_DISTRIBUTOR")` +- `.requestMatchers(HttpMethod.POST, "/api/show").hasAuthority("ROLE_DISTRIBUTOR")` - `.anyRequest().authenticated()` --- @@ -61,7 +61,7 @@ ## 5. 확장 가이드(인가 규칙 추가 방법) - 새로운 엔드포인트 추가 시 규칙 예시: - 공개 엔드포인트(회원가입/로그인 유사): `.requestMatchers(HttpMethod.POST, "/api/xxx").permitAll()` - - 역할 제한 엔드포인트: `.requestMatchers(HttpMethod.PUT, "/api/movies/{id}").hasAuthority("ROLE_ADMIN")` + - 역할 제한 엔드포인트: `.requestMatchers(HttpMethod.PUT, "/api/show/{id}").hasAuthority("ROLE_ADMIN")` - 복수 권한 허용: `.requestMatchers(HttpMethod.POST, "/api/screening").hasAnyAuthority("ROLE_DISTRIBUTOR", "ROLE_ADMIN")` - 규칙 배치 위치: `SecurityConfig.apiChain`의 `authorizeHttpRequests` 빌더에 메서드/경로/권한을 추가합니다. - 테스트: 추가/변경 시 반드시 보안 통합 테스트를 작성하여 401/403, 성공 경로를 검증하십시오. (예: `adapter/security/*Test.java`, `webapi/**` 스펙 테스트) diff --git a/docs/specs/policy/test.md b/docs/specs/policy/test.md index d8cc766..83609c8 100644 --- a/docs/specs/policy/test.md +++ b/docs/specs/policy/test.md @@ -133,7 +133,7 @@ - 로그인: `docs/specs/api/login.md` - 회원가입: `docs/specs/api/member_register.md` - 토큰 재발급: `docs/specs/api/reissue.md` - - 영화 등록: `docs/specs/api/movie_register.md` + - 공연 등록: `docs/specs/api/show_register.md` - 체크박스는 수용 기준(acceptance criteria)로 간주하며, 누락 시 테스트 보완 또는 문서 동기화가 필요하다. --- @@ -158,7 +158,7 @@ - 통합 테스트: 시나리오 중심 폴더 구조 사용 가능 - 예시) - POST `/api/auth/login`: `src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java` - - GET `/api/movies`: `src/test/java/org/mandarin/booking/webapi/movies/GET_specs.java` + - GET `/api/show`: `src/test/java/org/mandarin/booking/webapi/show/GET_specs.java` - 아키텍처 테스트: `arch/*` --- diff --git a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java index 2266591..af14a58 100644 --- a/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java +++ b/src/main/java/org/mandarin/booking/adapter/security/SecurityConfig.java @@ -39,7 +39,7 @@ public SecurityFilterChain apiChain(HttpSecurity http, .requestMatchers(HttpMethod.POST, "/api/member").permitAll() .requestMatchers("/api/auth/login").permitAll() .requestMatchers("/api/auth/reissue").permitAll() - .requestMatchers(HttpMethod.POST, "/api/movie").hasAuthority("ROLE_DISTRIBUTOR") + .requestMatchers(HttpMethod.POST, "/api/show").hasAuthority("ROLE_DISTRIBUTOR") .anyRequest().authenticated() ) .formLogin(AbstractHttpConfigurer::disable) diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java b/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java deleted file mode 100644 index c92eefe..0000000 --- a/src/main/java/org/mandarin/booking/adapter/webapi/MovieController.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.mandarin.booking.adapter.webapi; - -import jakarta.validation.Valid; -import org.mandarin.booking.app.port.MovieRegisterer; -import org.mandarin.booking.domain.movie.MovieRegisterRequest; -import org.mandarin.booking.domain.movie.MovieRegisterResponse; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/movie") -public record MovieController(MovieRegisterer movieRegisterer) { - - @PostMapping - public MovieRegisterResponse register(@RequestBody @Valid MovieRegisterRequest request) { - return movieRegisterer.register(request); - } -} diff --git a/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java b/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java new file mode 100644 index 0000000..497fcaa --- /dev/null +++ b/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java @@ -0,0 +1,21 @@ +package org.mandarin.booking.adapter.webapi; + +import jakarta.validation.Valid; +import org.mandarin.booking.app.port.ShowRegisterer; +import org.mandarin.booking.domain.show.ShowRegisterRequest; +import org.mandarin.booking.domain.show.ShowRegisterResponse; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/show") +public record ShowController(ShowRegisterer showRegisterer) { + + @PostMapping + public ShowRegisterResponse register(@RequestBody @Valid ShowRegisterRequest request) { + return showRegisterer.register(request); + } +} + diff --git a/src/main/java/org/mandarin/booking/app/MovieService.java b/src/main/java/org/mandarin/booking/app/MovieService.java deleted file mode 100644 index 1ab43ac..0000000 --- a/src/main/java/org/mandarin/booking/app/MovieService.java +++ /dev/null @@ -1,25 +0,0 @@ -package org.mandarin.booking.app; - -import static java.util.Objects.requireNonNull; - -import lombok.RequiredArgsConstructor; -import org.mandarin.booking.app.persist.MovieCommandRepository; -import org.mandarin.booking.app.port.MovieRegisterer; -import org.mandarin.booking.domain.movie.Movie; -import org.mandarin.booking.domain.movie.MovieCreateCommand; -import org.mandarin.booking.domain.movie.MovieRegisterRequest; -import org.mandarin.booking.domain.movie.MovieRegisterResponse; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class MovieService implements MovieRegisterer { - private final MovieCommandRepository commandRepository; - @Override - public MovieRegisterResponse register(MovieRegisterRequest request) { - var command = MovieCreateCommand.from(request); - var movie = Movie.create(command); - var savedMovie = commandRepository.insert(movie); - return new MovieRegisterResponse(requireNonNull(savedMovie.getId())); - } -} diff --git a/src/main/java/org/mandarin/booking/app/ShowRegisterValidator.java b/src/main/java/org/mandarin/booking/app/ShowRegisterValidator.java new file mode 100644 index 0000000..3c48dd6 --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/ShowRegisterValidator.java @@ -0,0 +1,20 @@ +package org.mandarin.booking.app; + +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.persist.ShowQueryRepository; +import org.mandarin.booking.domain.show.ShowException; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ShowRegisterValidator { + private final ShowQueryRepository queryRepository; + + public void checkDuplicateTitle(String title) { + if (queryRepository.existsByName(title)) { + throw new ShowException("이미 존재하는 공연 이름입니다:" + title); + } + } + + +} diff --git a/src/main/java/org/mandarin/booking/app/ShowService.java b/src/main/java/org/mandarin/booking/app/ShowService.java new file mode 100644 index 0000000..7df168c --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/ShowService.java @@ -0,0 +1,31 @@ +package org.mandarin.booking.app; + +import static java.util.Objects.requireNonNull; + +import lombok.RequiredArgsConstructor; +import org.mandarin.booking.app.persist.ShowCommandRepository; +import org.mandarin.booking.app.port.ShowRegisterer; +import org.mandarin.booking.domain.show.Show; +import org.mandarin.booking.domain.show.ShowCreateCommand; +import org.mandarin.booking.domain.show.ShowRegisterRequest; +import org.mandarin.booking.domain.show.ShowRegisterResponse; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ShowService implements ShowRegisterer { + private final ShowCommandRepository commandRepository; + private final ShowRegisterValidator validator; + + @Override + public ShowRegisterResponse register(ShowRegisterRequest request) { + var command = ShowCreateCommand.from(request); + var show = Show.create(command); + + validator.checkDuplicateTitle(show.getTitle()); + + var saved = commandRepository.insert(show); + return new ShowRegisterResponse(requireNonNull(saved.getId())); + } +} + diff --git a/src/main/java/org/mandarin/booking/app/persist/MovieRepository.java b/src/main/java/org/mandarin/booking/app/persist/MovieRepository.java deleted file mode 100644 index 9526635..0000000 --- a/src/main/java/org/mandarin/booking/app/persist/MovieRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.mandarin.booking.app.persist; - -import org.mandarin.booking.domain.movie.Movie; -import org.springframework.data.repository.Repository; - -public interface MovieRepository extends Repository { - Movie save(Movie movie); -} diff --git a/src/main/java/org/mandarin/booking/app/persist/MovieCommandRepository.java b/src/main/java/org/mandarin/booking/app/persist/ShowCommandRepository.java similarity index 54% rename from src/main/java/org/mandarin/booking/app/persist/MovieCommandRepository.java rename to src/main/java/org/mandarin/booking/app/persist/ShowCommandRepository.java index b774c9a..c5fb793 100644 --- a/src/main/java/org/mandarin/booking/app/persist/MovieCommandRepository.java +++ b/src/main/java/org/mandarin/booking/app/persist/ShowCommandRepository.java @@ -1,17 +1,18 @@ package org.mandarin.booking.app.persist; import lombok.RequiredArgsConstructor; -import org.mandarin.booking.domain.movie.Movie; +import org.mandarin.booking.domain.show.Show; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @Repository @Transactional @RequiredArgsConstructor -public class MovieCommandRepository { - private final MovieRepository jpaRepository; +public class ShowCommandRepository { + private final ShowRepository jpaRepository; - public Movie insert(Movie movie){ - return jpaRepository.save(movie); + public Show insert(Show show) { + return jpaRepository.save(show); } } + diff --git a/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java b/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java new file mode 100644 index 0000000..242faac --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/persist/ShowQueryRepository.java @@ -0,0 +1,17 @@ +package org.mandarin.booking.app.persist; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ShowQueryRepository { + private final ShowRepository jpaRepository; + + + public boolean existsByName(String title) { + return jpaRepository.existsByTitle(title); + } +} diff --git a/src/main/java/org/mandarin/booking/app/persist/ShowRepository.java b/src/main/java/org/mandarin/booking/app/persist/ShowRepository.java new file mode 100644 index 0000000..74c52a7 --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/persist/ShowRepository.java @@ -0,0 +1,11 @@ +package org.mandarin.booking.app.persist; + +import org.mandarin.booking.domain.show.Show; +import org.springframework.data.repository.Repository; + +public interface ShowRepository extends Repository { + Show save(Show show); + + boolean existsByTitle(String title); +} + diff --git a/src/main/java/org/mandarin/booking/app/port/MovieRegisterer.java b/src/main/java/org/mandarin/booking/app/port/MovieRegisterer.java deleted file mode 100644 index b781160..0000000 --- a/src/main/java/org/mandarin/booking/app/port/MovieRegisterer.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.mandarin.booking.app.port; - -import org.mandarin.booking.domain.movie.MovieRegisterRequest; -import org.mandarin.booking.domain.movie.MovieRegisterResponse; - -public interface MovieRegisterer { - MovieRegisterResponse register(MovieRegisterRequest request); -} diff --git a/src/main/java/org/mandarin/booking/app/port/ShowRegisterer.java b/src/main/java/org/mandarin/booking/app/port/ShowRegisterer.java new file mode 100644 index 0000000..fbff807 --- /dev/null +++ b/src/main/java/org/mandarin/booking/app/port/ShowRegisterer.java @@ -0,0 +1,9 @@ +package org.mandarin.booking.app.port; + +import org.mandarin.booking.domain.show.ShowRegisterRequest; +import org.mandarin.booking.domain.show.ShowRegisterResponse; + +public interface ShowRegisterer { + ShowRegisterResponse register(ShowRegisterRequest request); +} + diff --git a/src/main/java/org/mandarin/booking/domain/EnumRequest.java b/src/main/java/org/mandarin/booking/domain/EnumRequest.java new file mode 100644 index 0000000..e824e70 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/EnumRequest.java @@ -0,0 +1,22 @@ +package org.mandarin.booking.domain; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = EnumRequestValidator.class) +public @interface EnumRequest { + + Class> value(); + + String message() default "invalid value, must be one of valid enum types"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java b/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java new file mode 100644 index 0000000..14b72fa --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java @@ -0,0 +1,30 @@ +package org.mandarin.booking.domain; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class EnumRequestValidator implements ConstraintValidator { + private Class> clazz; + private String message; + + @Override + public void initialize(EnumRequest constraintAnnotation) { + clazz = constraintAnnotation.value(); + message = constraintAnnotation.message(); + } + + @Override + public boolean isValid(String s, ConstraintValidatorContext context) { + clazz.getEnumConstants(); + for (Enum enumConstant : clazz.getEnumConstants()) { + if (enumConstant.name().equals(s)) { + return true; + } + } + + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(message) + .addConstraintViolation(); + return false; + } +} diff --git a/src/main/java/org/mandarin/booking/domain/movie/Movie.java b/src/main/java/org/mandarin/booking/domain/movie/Movie.java deleted file mode 100644 index bf36e88..0000000 --- a/src/main/java/org/mandarin/booking/domain/movie/Movie.java +++ /dev/null @@ -1,58 +0,0 @@ -package org.mandarin.booking.domain.movie; - -import jakarta.persistence.CollectionTable; -import jakarta.persistence.Column; -import jakarta.persistence.ElementCollection; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.JoinColumn; -import java.time.LocalDate; -import java.util.HashSet; -import java.util.Set; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.NoArgsConstructor; -import org.mandarin.booking.domain.AbstractEntity; - -@Entity -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class Movie extends AbstractEntity { - private String title; - - private String director; - - private Integer runtimeMinutes; - - @Enumerated(EnumType.STRING) - private Genre genre; - - private LocalDate releaseDate; - - @Enumerated(EnumType.STRING) - private Rating rating; - - private String synopsis; - - private String posterUrl; - - - @ElementCollection - @CollectionTable(name = "movie_cast", joinColumns = @JoinColumn(name = "movie_id")) - @Column(name = "actor_name") - private Set casts = new HashSet<>(); - - public static Movie create(MovieCreateCommand command) { - return new Movie(command.getTitle(), command.getDirector(), command.getRuntimeMinutes(), command.getGenre(), - command.getReleaseDate(), command.getRating(), command.getSynopsis(), command.getPosterUrl(), command.getCasts()); - } - - public enum Genre { - ACTION, DRAMA, COMEDY, THRILLER, ROMANCE, SF, FANTASY, HORROR, ANIMATION, DOCUMENTARY, ETC - } - - public enum Rating { - ALL, AGE12, AGE15, AGE18 - } -} diff --git a/src/main/java/org/mandarin/booking/domain/movie/MovieCreateCommand.java b/src/main/java/org/mandarin/booking/domain/movie/MovieCreateCommand.java deleted file mode 100644 index e9d8a7f..0000000 --- a/src/main/java/org/mandarin/booking/domain/movie/MovieCreateCommand.java +++ /dev/null @@ -1,49 +0,0 @@ -package org.mandarin.booking.domain.movie; - -import java.time.LocalDate; -import java.util.HashSet; -import java.util.Set; -import lombok.Getter; -import org.mandarin.booking.domain.movie.Movie.Genre; -import org.mandarin.booking.domain.movie.Movie.Rating; - -@Getter -public class MovieCreateCommand { - private final String title; - private final Genre genre; - private final int runtimeMinutes; - private final String director; - private final String synopsis; - private final String posterUrl; - private final LocalDate releaseDate; - private final Rating rating; - private final Set casts; - - private MovieCreateCommand(String title, Genre genre, int runtimeMinutes, String director, String synopsis, - String posterUrl, LocalDate releaseDate, Rating rating, Set casts) { - this.title = title; - this.genre = genre; - this.runtimeMinutes = runtimeMinutes; - this.director = director; - this.synopsis = synopsis; - this.posterUrl = posterUrl; - this.releaseDate = releaseDate; - this.rating = rating; - this.casts = casts; - } - - public static MovieCreateCommand from(MovieRegisterRequest request) { - - return new MovieCreateCommand( - request.title(), - Genre.valueOf(request.genre()), - request.runtimeMinutes(), - request.director(), - request.synopsis(), - request.posterUrl(), - LocalDate.parse(request.releaseDate()), - Rating.valueOf(request.rating()), - new HashSet<>(request.casts()) - ); - } -} diff --git a/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java b/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java deleted file mode 100644 index 4a2ea4a..0000000 --- a/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterRequest.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.mandarin.booking.domain.movie; - -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; -import java.util.List; - -public record MovieRegisterRequest( - @NotBlank(message = "Title must not be blank") - String title, - - @NotBlank(message = "Director must not be blank") - String director, - - @NotNull(message = "Runtime minutes must not be null") - @Min(value = 0, message = "Runtime minutes must be non-negative") - Integer runtimeMinutes, - - @NotBlank(message = "Genre must not be blank") - String genre, - - @NotBlank(message = "Release date must not be blank") - @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "releaseDate must be yyyy-MM-dd") - String releaseDate, - - @NotBlank(message = "Rating must not be blank") - String rating, - - String synopsis, - String posterUrl, - List casts) { -} diff --git a/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterResponse.java b/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterResponse.java deleted file mode 100644 index 496e724..0000000 --- a/src/main/java/org/mandarin/booking/domain/movie/MovieRegisterResponse.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.mandarin.booking.domain.movie; - -public record MovieRegisterResponse(Long movieId) { -} diff --git a/src/main/java/org/mandarin/booking/domain/movie/package-info.java b/src/main/java/org/mandarin/booking/domain/movie/package-info.java deleted file mode 100644 index baa5f7e..0000000 --- a/src/main/java/org/mandarin/booking/domain/movie/package-info.java +++ /dev/null @@ -1,3 +0,0 @@ -@NonNullApi -package org.mandarin.booking.domain.movie; -import org.springframework.lang.NonNullApi; diff --git a/src/main/java/org/mandarin/booking/domain/show/Show.java b/src/main/java/org/mandarin/booking/domain/show/Show.java new file mode 100644 index 0000000..df63be3 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/show/Show.java @@ -0,0 +1,61 @@ +package org.mandarin.booking.domain.show; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.mandarin.booking.domain.AbstractEntity; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Show extends AbstractEntity { + private String title; + + @Enumerated(EnumType.STRING) + private Type type; + + @Enumerated(EnumType.STRING) + private Rating rating; + + private String synopsis; + + private String posterUrl; + + private LocalDate performanceStartDate; + + private LocalDate performanceEndDate; + + public static Show create(ShowCreateCommand command) { + var startDate = command.getPerformanceStartDate(); + var endDate = command.getPerformanceEndDate(); + + if (startDate.isAfter(endDate)) { + throw new ShowException("공연 시작 날짜는 종료 날짜 이후에 있을 수 없습니다."); + } + + return new Show( + command.getTitle(), + command.getType(), + command.getRating(), + command.getSynopsis(), + command.getPosterUrl(), + startDate, + endDate + ); + } + + public enum Type { + MUSICAL, PLAY, CONCERT, OPERA, DANCE, CLASSICAL, ETC + } + + public enum Rating { + ALL, AGE12, AGE15, AGE18 + } +} + diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowCreateCommand.java b/src/main/java/org/mandarin/booking/domain/show/ShowCreateCommand.java new file mode 100644 index 0000000..d848c88 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/show/ShowCreateCommand.java @@ -0,0 +1,41 @@ +package org.mandarin.booking.domain.show; + +import java.time.LocalDate; +import lombok.Getter; +import org.mandarin.booking.domain.show.Show.Rating; +import org.mandarin.booking.domain.show.Show.Type; + +@Getter +public class ShowCreateCommand { + private final String title; + private final Type type; + private final Rating rating; + private final String synopsis; + private final String posterUrl; + private final LocalDate performanceStartDate; + private final LocalDate performanceEndDate; + + private ShowCreateCommand(String title, Type type, Rating rating, String synopsis, String posterUrl, + LocalDate performanceStartDate, LocalDate performanceEndDate) { + this.title = title; + this.type = type; + this.rating = rating; + this.synopsis = synopsis; + this.posterUrl = posterUrl; + this.performanceStartDate = performanceStartDate; + this.performanceEndDate = performanceEndDate; + } + + public static ShowCreateCommand from(ShowRegisterRequest request) { + return new ShowCreateCommand( + request.title(), + Type.valueOf(request.type()), + Rating.valueOf(request.rating()), + request.synopsis(), + request.posterUrl(), + request.performanceStartDate(), + request.performanceEndDate() + ); + } +} + diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowException.java b/src/main/java/org/mandarin/booking/domain/show/ShowException.java new file mode 100644 index 0000000..c97d86f --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/show/ShowException.java @@ -0,0 +1,9 @@ +package org.mandarin.booking.domain.show; + +import org.mandarin.booking.domain.DomainException; + +public class ShowException extends DomainException { + public ShowException(String message) { + super(message); + } +} diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java b/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java new file mode 100644 index 0000000..b679618 --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java @@ -0,0 +1,37 @@ +package org.mandarin.booking.domain.show; + +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import org.mandarin.booking.domain.EnumRequest; +import org.mandarin.booking.domain.show.Show.Rating; +import org.mandarin.booking.domain.show.Show.Type; + +public record ShowRegisterRequest( + @NotBlank(message = "title is required") + String title, + + @NotBlank(message = "type is required") + @EnumRequest(value = Type.class, message = "invalid type") + String type, + + @NotBlank(message = "rating is required") + @EnumRequest(value = Rating.class, message = "invalid rating") + String rating, + + @NotBlank(message = "synopsis is required") + String synopsis, + + @NotBlank(message = "posterUrl is required") + String posterUrl, + + @NotNull(message = "performanceStartDate is required") + @FutureOrPresent(message = "performanceStartDate must be today or future") + LocalDate performanceStartDate, + + @NotNull(message = "performanceEndDate is required") + LocalDate performanceEndDate +) { +} + diff --git a/src/main/java/org/mandarin/booking/domain/show/ShowRegisterResponse.java b/src/main/java/org/mandarin/booking/domain/show/ShowRegisterResponse.java new file mode 100644 index 0000000..e65bf1f --- /dev/null +++ b/src/main/java/org/mandarin/booking/domain/show/ShowRegisterResponse.java @@ -0,0 +1,5 @@ +package org.mandarin.booking.domain.show; + +public record ShowRegisterResponse(Long showId) { +} + diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 8bf4f10..71ffcf8 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -23,6 +23,6 @@ jwt: access: 600000 refresh: 1800000 -logging: - level: - org.springframework.security: TRACE +#logging: +# level: +# org.springframework.security: TRACE diff --git a/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java deleted file mode 100644 index 56274e9..0000000 --- a/src/test/java/org/mandarin/booking/webapi/movie/POST_specs.java +++ /dev/null @@ -1,183 +0,0 @@ -package org.mandarin.booking.webapi.movie; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; -import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; -import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; -import static org.mandarin.booking.domain.member.MemberAuthority.DISTRIBUTOR; - -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; -import org.mandarin.booking.IntegrationTest; -import org.mandarin.booking.IntegrationTestUtils; -import org.mandarin.booking.domain.movie.MovieRegisterRequest; -import org.mandarin.booking.domain.movie.MovieRegisterResponse; -import org.springframework.beans.factory.annotation.Autowired; - -@IntegrationTest -@DisplayName("POST /api/movie") -public class POST_specs { - - @Test - void 올바른_요청을_보내면_status가_SUCCESS이다( - @Autowired IntegrationTestUtils testUtils - ) { - // Arrange - var authToken = testUtils.getAuthToken(DISTRIBUTOR); - - var request = generateMovieRegisterRequest(); - - // Act - var response = testUtils.post( - "/api/movie", - request - ) - .withHeader("Authorization", authToken) - .assertSuccess(MovieRegisterResponse.class); - - // Assert - assertThat(response.getStatus()).isEqualTo(SUCCESS); - } - - @Test - void Authorization_헤더에_유효한_accessToken이_없으면_status가_UNAUTHORIZED이다( - @Autowired IntegrationTestUtils testUtils - ) { - // Arrange - var request = generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", "2010-07-21", "AGE12"); - - // Act - var response = testUtils.post( - "/api/movie", - request - ) - .assertFailure(); - - // Assert - assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); - } - - @ParameterizedTest - @MethodSource("org.mandarin.booking.webapi.movie.POST_specs#nullOrBlankElementRequests") - void title_director_runtimeMinutes_genre_releaseDate_rating이_비어있으면_BAD_REQUEST이다( - MovieRegisterRequest request, - @Autowired IntegrationTestUtils testUtils - ) { - // Arrange - var authToken = testUtils.getAuthToken(DISTRIBUTOR); - - // Act - var response = testUtils.post( - "/api/movie", - request - ) - .withHeader("Authorization", authToken) - .assertFailure(); - - // Assert - assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); - } - - @Test - void runtimeMinutes은_0_미만이면_BAD_REQUEST이다( - @Autowired IntegrationTestUtils testUtils - ) { - // Arrange - var authToken = testUtils.getAuthToken(DISTRIBUTOR); - - // Act - var response = testUtils.post( - "/api/movie", - generateMovieRegisterRequest("영화 제목", "감독 이름", -1, "SF", "2010-07-21", "AGE12") - ) - .withHeader("Authorization", authToken) - .assertFailure(); - - // Assert - assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); - } - - @Test - void releaseDate는_yyyy_MM_dd_형태를_준수하지_않으면_BAD_REQUEST이다( - @Autowired IntegrationTestUtils testUtils - ) { - // Arrange - var authToken = testUtils.getAuthToken(DISTRIBUTOR); - // 잘못된 날짜 형식 - var request = generateMovieRegisterRequest( - "영화 제목", "감독 이름", 148, "SF", "21-07-2010", "AGE12" - ); - - // Act - var response = testUtils.post( - "/api/movie", - request - ) - .withHeader("Authorization", authToken) - .assertFailure(); - - // Assert - assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); - } - - @Test - void 올바른_요청을_보내면_응답_본문에_movieId가_존재한다( - @Autowired IntegrationTestUtils testUtils - ) { - // Arrange - var authToken = testUtils.getAuthToken(DISTRIBUTOR); - var request = generateMovieRegisterRequest(); - - // Act - var response = testUtils.post( - "/api/movie", - request - ) - .withHeader("Authorization", authToken) - .assertSuccess(MovieRegisterResponse.class); - - // Assert - assertThat(response.getData().movieId()).isNotNull(); - } - - static List nullOrBlankElementRequests(){ - return List.of( - generateMovieRegisterRequest("", "감독 이름", 148, "SF", "2010-07-21", "AGE12"), - generateMovieRegisterRequest("영화 제목", "", 148, "SF", "2010-07-21", "AGE12"), - generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "", "2010-07-21", "AGE12"), - generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", null, "AGE12"), - generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", "2010-07-21", ""), - generateMovieRegisterRequest(null, "감독 이름", 148, "SF", "2010-07-21", "AGE12"), - generateMovieRegisterRequest("영화 제목", null, 148, "SF", "2010-07-21", "AGE12"), - generateMovieRegisterRequest("영화 제목", "감독 이름", 148, null, "2010-07-21", "AGE12"), - generateMovieRegisterRequest("영화 제목", "감독 이름", null, "SF", "2010-07-21", "AGE12"), - generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", null, "AGE12"), - generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", "2010-07-21", null) - ); - } - - - private static MovieRegisterRequest generateMovieRegisterRequest(String title, String director, Integer runtimeMinutes, - String genre, String releaseDate, String rating) { - return new MovieRegisterRequest( - title, - director, - runtimeMinutes, - genre, - releaseDate, - rating, - "타인의 꿈속에 진입해 아이디어를 주입하는 특수 임무를 수행하는 이야기.", - "https://example.com/posters/inception.jpg", - List.of("레오나르도 디카프리오", - "조셉 고든레빗", - "엘렌 페이지") - ); - } - - private static MovieRegisterRequest generateMovieRegisterRequest() { - return generateMovieRegisterRequest("영화 제목", "감독 이름", 148, "SF", "2010-07-21", "AGE12"); - } -} diff --git a/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java b/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java new file mode 100644 index 0000000..5d2a8b7 --- /dev/null +++ b/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java @@ -0,0 +1,236 @@ +package org.mandarin.booking.webapi.show; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mandarin.booking.adapter.webapi.ApiStatus.BAD_REQUEST; +import static org.mandarin.booking.adapter.webapi.ApiStatus.INTERNAL_SERVER_ERROR; +import static org.mandarin.booking.adapter.webapi.ApiStatus.SUCCESS; +import static org.mandarin.booking.adapter.webapi.ApiStatus.UNAUTHORIZED; +import static org.mandarin.booking.domain.member.MemberAuthority.DISTRIBUTOR; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mandarin.booking.IntegrationTest; +import org.mandarin.booking.IntegrationTestUtils; +import org.mandarin.booking.domain.show.ShowRegisterRequest; +import org.mandarin.booking.domain.show.ShowRegisterResponse; +import org.springframework.beans.factory.annotation.Autowired; + +@IntegrationTest +@DisplayName("POST /api/show") +public class POST_specs { + + @Test + void 올바른_요청을_보내면_status가_SUCCESS이다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var authToken = testUtils.getAuthToken(DISTRIBUTOR); + var request = validShowRegisterRequest(); + + // Act + var response = testUtils.post( + "/api/show", + request + ) + .withHeader("Authorization", authToken) + .assertSuccess(ShowRegisterResponse.class); + + // Assert + assertThat(response.getStatus()).isEqualTo(SUCCESS); + } + + @Test + void Authorization_헤더에_유효한_accessToken이_없으면_status가_UNAUTHORIZED이다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var request = validShowRegisterRequest(); + + // Act + var response = testUtils.post( + "/api/show", + request + ) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED); + } + + @ParameterizedTest + @MethodSource("org.mandarin.booking.webapi.show.POST_specs#nullOrBlankElementRequests") + void title_type_rating_synopsis_posterUrl_performanceDates가_비어있으면_BAD_REQUEST이다( + ShowRegisterRequest request, + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var authToken = testUtils.getAuthToken(DISTRIBUTOR); + + // Act + var response = testUtils.post( + "/api/show", + request + ) + .withHeader("Authorization", authToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void 허용되지_않은_type이면_BAD_REQUEST이다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var authToken = testUtils.getAuthToken(DISTRIBUTOR); + var request = new ShowRegisterRequest( + "공연 제목", + "MOVIE", // invalid type + "AGE12", + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().plusDays(30) + ); + + // Act + var response = testUtils.post( + "/api/show", + request + ) + .withHeader("Authorization", authToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void 올바른_요청을_보내면_응답_본문에_showId가_존재한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var authToken = testUtils.getAuthToken(DISTRIBUTOR); + var request = validShowRegisterRequest(); + + // Act + var response = testUtils.post( + "/api/show", + request + ) + .withHeader("Authorization", authToken) + .assertSuccess(ShowRegisterResponse.class); + + // Assert + assertThat(response.getData().showId()).isNotNull(); + } + + @Test + void 공연_시작일은_공연_종료일_이후면_INTERNAL_SERVER_ERROR이다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var authToken = testUtils.getAuthToken(DISTRIBUTOR); + var request = new ShowRegisterRequest( + "공연 제목", + "MUSICAL", + "AGE12", + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().minusDays(1) + ); + + // Act + var response = testUtils.post( + "/api/show", + request + ) + .withHeader("Authorization", authToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(INTERNAL_SERVER_ERROR); + assertThat(response.getData()).isEqualTo("공연 시작 날짜는 종료 날짜 이후에 있을 수 없습니다."); + + } + + @Test + void 중복된_제목의_공연을_등록하면_INTERNAL_SERVER_ERROR가_발생한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var authToken = testUtils.getAuthToken(DISTRIBUTOR); + var request = validShowRegisterRequest(); + testUtils.post( + "/api/show", + request + ) + .withHeader("Authorization", authToken) + .assertSuccess(ShowRegisterResponse.class); + + var duplicateTitleRequest = validShowRegisterRequest(request.title()); + + // Act + var response = testUtils.post( + "/api/show", + duplicateTitleRequest + ) + .withHeader("Authorization", authToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(INTERNAL_SERVER_ERROR); + assertThat(response.getData()).contains("이미 존재하는 공연 이름입니다:"); + } + + static List nullOrBlankElementRequests() { + return List.of( + new ShowRegisterRequest("", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", + LocalDate.now(), LocalDate.now().plusDays(1)), + new ShowRegisterRequest("공연 제목", "", "ALL", "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), + LocalDate.now().plusDays(1)), + new ShowRegisterRequest("공연 제목", "MUSICAL", "", "공연 줄거리", "https://example.com/poster.jpg", + LocalDate.now(), LocalDate.now().plusDays(1)), + new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "", "https://example.com/poster.jpg", + LocalDate.now(), LocalDate.now().plusDays(1)), + new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "공연 줄거리", "", LocalDate.now(), + LocalDate.now().plusDays(1)), + new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", null, + LocalDate.now().plusDays(1)), + new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", + LocalDate.now(), null) + ); + } + + private ShowRegisterRequest validShowRegisterRequest() { + return new ShowRegisterRequest( + UUID.randomUUID().toString().substring(0, 10), + "MUSICAL", + "AGE12", + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().plusDays(30) + ); + } + + private ShowRegisterRequest validShowRegisterRequest(String title) { + return new ShowRegisterRequest( + title, + "MUSICAL", + "AGE12", + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().plusDays(30) + ); + } +} +