diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..8af972cd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..95c45b8d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + build-test: + name: Build and Test + runs-on: ubuntu-latest + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: 'gradle' + + - name: Grant execute permission for Gradle + run: chmod +x gradlew + + - name: Run tests + run: ./gradlew test --no-daemon + env: + SPRING_PROFILES_ACTIVE: test + + - name: Build application + run: ./gradlew clean bootJar --no-daemon diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..4eb1048e --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +*# +*.iml +*.ipr +*.iws +*.jar +!gradle/wrapper/gradle-wrapper.jar +*.sw? +*~ +.#* +.*.md.html +.DS_Store +.attach_pid* +.classpath +.factorypath +.gradle +.metadata +.project +.recommenders +.settings +.springBeans +.vscode +/code +MANIFEST.MF +_site/ +activemq-data +bin +build +!/**/src/**/bin +!/**/src/**/build +build.log +dependency-reduced-pom.xml +dump.rdb +interpolated*.xml +lib/ +manifest.yml +out +overridedb.* +target +.flattened-pom.xml +secrets.yml +.gradletasknamecache +.sts4-cache + +.idea +.env +*.env +logs/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..e3b1dbdd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM gradle:8.14-jdk21 AS build +WORKDIR /app +COPY . . +RUN gradle clean bootJar --no-daemon + +FROM eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build /app/build/libs/*.jar app.jar +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/HELP.md b/HELP.md new file mode 100644 index 00000000..ea6a7e63 --- /dev/null +++ b/HELP.md @@ -0,0 +1,22 @@ +# Getting Started + +### Reference Documentation +For further reference, please consider the following sections: + +* [Official Gradle documentation](https://docs.gradle.org) +* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/4.0.3/gradle-plugin) +* [Create an OCI image](https://docs.spring.io/spring-boot/4.0.3/gradle-plugin/packaging-oci-image.html) +* [Spring Web](https://docs.spring.io/spring-boot/4.0.3/reference/web/servlet.html) + +### Guides +The following guides illustrate how to use some features concretely: + +* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) +* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) +* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) + +### Additional Links +These additional references should also help you: + +* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle) + diff --git a/README.md b/README.md index 96420f86..1aa3fb22 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,1697 @@ # spring-cgv-23rd CEOS 23기 백엔드 스터디 - CGV 클론 코딩 프로젝트 + +
+8주차 데이터베이스 성능 최적화 미션 정리 + +## 1. 트랜잭션 전파 속성 조사해보기 + +트랜잭션 전파 속성은 이미 실행 중인 트랜잭션이 있을 때 새로 호출된 메서드가 그 트랜잭션에 참여할지, 별도 트랜잭션을 만들지, 트랜잭션 없이 실행할지를 정하는 규칙이다. + +| 전파 속성 | 개념 | 적합한 상황 | +| --- | --- | --- | +| `REQUIRED` | 기존 트랜잭션이 있으면 참여하고, 없으면 새로 생성 | 대부분의 일반적인 쓰기 유스케이스 | +| `REQUIRES_NEW` | 기존 트랜잭션을 잠시 중단하고 항상 새 트랜잭션 생성 | 감사 로그, 실패 이력 저장처럼 본 트랜잭션과 분리해야 하는 작업 | +| `SUPPORTS` | 기존 트랜잭션이 있으면 참여하고, 없으면 트랜잭션 없이 실행 | 단순 조회 보조 로직 | +| `NOT_SUPPORTED` | 트랜잭션을 중단하고 트랜잭션 없이 실행 | 오래 걸리는 외부 API 호출, 파일 처리 | +| `MANDATORY` | 반드시 기존 트랜잭션 안에서만 실행 | 상위 유스케이스의 트랜잭션 경계를 강제해야 하는 내부 메서드 | +| `NEVER` | 트랜잭션이 있으면 예외 발생 | 트랜잭션 안에서 실행되면 안 되는 작업 | +| `NESTED` | 기존 트랜잭션 내부에서 저장점을 만들고 부분 롤백 | 일부 단계만 롤백 가능한 DB 작업 | + +## 2. 현재 CGV 서비스 트랜잭션 분석 + +### 현재 구조 + +- 대부분의 조회 서비스는 클래스 레벨에 `@Transactional(readOnly = true)`를 두고, 생성/수정 메서드에만 `@Transactional`을 다시 선언한다. +- 예매와 매점 주문은 `TransactionTemplate`으로 트랜잭션 단계를 명시적으로 분리한다. +- 예매 생성은 좌석 선점 트랜잭션을 먼저 커밋한 뒤, 트랜잭션 밖에서 외부 결제 API를 호출하고, 결제 성공 후 별도 트랜잭션에서 예약을 완료한다. +- 매점 주문도 주문 생성, 외부 결제, 재고 차감 및 주문 완료 단계를 분리한다. +- 좌석 중복 예매는 `Screening` 비관적 락과 `reserved_seats`의 유니크 제약으로 방어한다. +- 매점 재고 차감은 `Inventory` 비관적 락을 사용하며, 데드락 가능성을 줄이기 위해 `productId` 오름차순으로 락을 획득한다. + +### 개선 판단 + +현재 구조에서 가장 중요한 점은 외부 결제 API 호출이 DB 트랜잭션 밖에서 실행된다는 것이다. 외부 API를 트랜잭션 안에서 호출하면 DB 락 점유 시간이 길어지고, 외부 결제 성공 후 내부 DB 커밋 실패 시 상태 불일치가 발생할 수 있다. 그래서 예매/매점 주문은 `REQUIRED` 전파 속성 하나로 긴 트랜잭션을 만들기보다, `TransactionTemplate`으로 짧은 DB 트랜잭션 여러 개를 나누는 현재 방식이 더 적합하다. + +추가로 `REQUIRES_NEW`를 별도 감사 로그 저장에 사용할 수 있지만, 현재 프로젝트에는 결제 이력 테이블이나 감사 로그 테이블이 없으므로 과도한 구조 변경으로 판단해 적용하지 않았다. + +## 3. 인덱스 종류 조사 + +| 인덱스 종류 | 개념 | 적합한 상황 | +| --- | --- | --- | +| B-Tree 인덱스 | 정렬된 트리 구조로 범위 검색과 정렬에 강함 | 대부분의 RDB 기본 인덱스, 날짜/숫자 범위 조회 | +| Hash 인덱스 | 해시 값 기반으로 동등 비교에 강함 | `=` 조건 중심 조회 | +| Unique 인덱스 | 중복 값을 막으면서 조회 속도도 개선 | 이메일, 결제 ID, 예매 번호, 좌석 중복 방지 | +| 복합 인덱스 | 여러 컬럼을 하나의 인덱스로 구성 | `user_id + status`, `cinema_id + product_id`처럼 함께 조회되는 조건 | +| Covering 인덱스 | 쿼리에 필요한 컬럼을 인덱스만으로 처리 | 조회 컬럼이 적고 반복 호출이 많은 목록 API | +| Full-text 인덱스 | 자연어 검색에 특화 | 영화 제목/설명 검색 | +| Spatial 인덱스 | 위치/좌표 데이터 검색에 특화 | 지도 기반 거리 검색 | + +## 4. 성능 최적화 적용 내용 + +### 1. 영화 목록 조회 인덱스 + +- 대상 쿼리: + - `MovieRepository.findAllByOrderBySalesRateDesc()` + - `findByReleaseDateLessThanEqualOrderBySalesRateDesc()` + - `findByReleaseDateGreaterThanOrderBySalesRateDesc()` +- 적용: + - `idx_movies_sales_rate` + - `idx_movies_release_date` +- 이유: + - 영화 목록은 메인 화면에서 반복 조회될 가능성이 높다. + - 예매율 정렬과 개봉일 기준 현재/상영 예정 영화 필터링에 사용되는 컬럼을 인덱스로 분리했다. + +### 2. 상영 일정 조회 인덱스 + +- 대상 쿼리: + - `ScreeningRepository.findByMovieId(movieId)` +- 적용: + - `idx_screenings_movie_start_time` +- 이유: + - 특정 영화 상세에서 상영 회차를 조회할 때 `movie_id` 조건이 사용된다. + - 향후 시작 시간순 정렬이 추가되어도 같은 복합 인덱스를 활용할 수 있다. + +### 3. 매점 주문 내역 조회 인덱스 + +- 대상 쿼리: + - `FoodOrderRepository.findByUserIdAndStatusWithFetchJoin(userId, COMPLETED)` +- 적용: + - `idx_food_orders_user_status` +- 이유: + - 사용자별 주문 내역 조회는 `user_id`와 결제 완료 상태를 함께 조건으로 사용한다. + - 복합 인덱스를 사용하면 전체 주문을 훑지 않고 특정 사용자의 완료 주문 범위를 좁힐 수 있다. + +### 4. 매점 재고 차감 락 조회 인덱스 + +- 대상 쿼리: + - `InventoryRepository.findByCinemaIdAndProductIdForUpdate(cinemaId, productId)` +- 적용: + - `idx_inventories_cinema_product` +- 이유: + - 매점 결제 완료 후 재고 차감 시 `cinema_id + product_id`로 재고 행을 찾고 비관적 락을 획득한다. + - 인덱스가 없으면 락을 걸 대상 행을 찾기 위해 더 많은 행을 스캔할 수 있고, 동시 주문 상황에서 락 대기 시간이 길어질 수 있다. + +### 5. 주문 상품 조회 인덱스 + +- 대상 쿼리: + - `OrderItemRepository.findByFoodOrderId(orderId)` +- 적용: + - `idx_order_items_order_id` +- 이유: + - 결제 성공 후 주문의 상품 목록을 다시 조회해 재고를 차감한다. + - 주문 ID 기준 조회는 주문 완료 흐름의 핵심 경로이므로 단일 인덱스로 조회 범위를 줄였다. + +### 6. 영화별 이벤트 조회 인덱스 + +- 대상 쿼리: + - `MovieEventRepository.findByMovieId(movieId)` +- 적용: + - `idx_movie_events_movie_id` +- 이유: + - 영화 상세 화면에서 연결된 이벤트를 조회할 수 있다. + - 연결 테이블은 데이터가 늘어날수록 외래키 조건 인덱스가 효과적이다. + +## 5. EXPLAIN 확인 방법 + +실제 MySQL에서 인덱스 적용 전후를 비교할 때는 아래 쿼리로 실행 계획을 확인한다. + +```sql +EXPLAIN SELECT * FROM movies ORDER BY sales_rate DESC; +EXPLAIN SELECT * FROM movies WHERE release_date <= CURRENT_DATE ORDER BY sales_rate DESC; +EXPLAIN SELECT * FROM screenings WHERE movie_id = 1; +EXPLAIN SELECT * FROM food_orders WHERE user_id = 1 AND status = 'COMPLETED'; +EXPLAIN SELECT * FROM inventories WHERE cinema_id = 1 AND product_id = 1 FOR UPDATE; +EXPLAIN SELECT * FROM order_items WHERE order_id = 1; +EXPLAIN SELECT * FROM movie_events WHERE movie_id = 1; +``` + +비교 기준은 다음과 같다. + +- `key`: 사용된 인덱스 이름이 의도한 인덱스로 잡히는지 확인 +- `type`: `ALL`이면 전체 스캔, `ref`/`range`면 인덱스 기반 접근 +- `rows`: 예상 스캔 행 수가 줄어드는지 확인 +- `Extra`: `Using filesort`, `Using temporary` 발생 여부 확인 + +### EXPLAIN 비교 결과 + +실제 Aiven MySQL에 인덱스를 생성한 뒤 DataGrip에서 `EXPLAIN` 결과를 비교했다. 외래키 컬럼으로 시작하는 인덱스는 MySQL이 외래키 검사용 인덱스로도 사용하기 때문에 `DROP INDEX`가 제한되었다. 그래서 실제 인덱스를 삭제하지 않고 `IGNORE INDEX`와 `USE INDEX`로 인덱스 사용 전/후 실행 계획을 비교했다. + +#### 1. 사용자별 완료 주문 조회 + +비교 쿼리: + +```sql +EXPLAIN SELECT * +FROM food_orders IGNORE INDEX (idx_food_orders_user_status) +WHERE user_id = 1 + AND status = 'COMPLETED'; + +EXPLAIN SELECT * +FROM food_orders USE INDEX (idx_food_orders_user_status) +WHERE user_id = 1 + AND status = 'COMPLETED'; +``` + +비교 결과: + +| 구분 | type | key | Extra | 해석 | +| --- | --- | --- | --- | --- | +| 인덱스 미사용 | `ALL` | `null` | `Using where` | 전체 주문 테이블을 스캔한 뒤 조건 필터링 | +| 인덱스 사용 | `ref` | `idx_food_orders_user_status` | `Using index condition` | `user_id`, `status` 복합 인덱스로 대상 주문 탐색 | + +![food_orders 인덱스 미사용](assets/week8-db-optimization/food-orders-before.png) +![food_orders 인덱스 사용](assets/week8-db-optimization/food-orders-after.png) + +#### 2. 영화관별 상품 재고 조회 + +비교 쿼리: + +```sql +EXPLAIN SELECT * +FROM inventories IGNORE INDEX (idx_inventories_cinema_product) +WHERE cinema_id = 1 + AND product_id = 1; + +EXPLAIN SELECT * +FROM inventories USE INDEX (idx_inventories_cinema_product) +WHERE cinema_id = 1 + AND product_id = 1; +``` + +비교 결과: + +| 구분 | type | key | key_len | ref | 해석 | +| --- | --- | --- | --- | --- | --- | +| 복합 인덱스 미사용 | `ref` | 외래키 보조 인덱스 | `8` | `const` | `cinema_id` 조건만 인덱스로 탐색 후 `product_id` 필터링 | +| 복합 인덱스 사용 | `ref` | `idx_inventories_cinema_product` | `16` | `const,const` | `cinema_id`, `product_id` 두 조건을 모두 복합 인덱스로 탐색 | + +![inventories 복합 인덱스 미사용](assets/week8-db-optimization/inventories-before.png) +![inventories 복합 인덱스 사용](assets/week8-db-optimization/inventories-after.png) + +#### 3. 개봉일 기준 영화 조회 + +비교 쿼리: + +```sql +EXPLAIN SELECT * +FROM movies IGNORE INDEX (idx_movies_release_date) +WHERE release_date <= CURRENT_DATE +ORDER BY sales_rate DESC; + +EXPLAIN SELECT * +FROM movies USE INDEX (idx_movies_release_date) +WHERE release_date <= CURRENT_DATE +ORDER BY sales_rate DESC; +``` + +비교 결과: + +| 구분 | type | key | Extra | 해석 | +| --- | --- | --- | --- | --- | +| 인덱스 미사용 | `ALL` | `null` | `Using where; Using filesort` | 전체 영화 테이블을 스캔하고 조건 필터링 후 별도 정렬 | +| 인덱스 사용 | `range` | `idx_movies_release_date` | `Using index condition; Using filesort` | 개봉일 조건은 인덱스 범위 탐색으로 처리, 예매율 정렬은 별도 정렬 유지 | + +`release_date` 인덱스는 개봉일 조건의 탐색 범위를 줄인다. 다만 `ORDER BY sales_rate DESC`는 다른 컬럼 기준 정렬이므로 `Using filesort`가 남는다. + +![movies release_date 인덱스 미사용](assets/week8-db-optimization/movies-release-before.png) +![movies release_date 인덱스 사용](assets/week8-db-optimization/movies-release-after.png) + +#### 4. 예매율 기준 영화 정렬 + +비교 쿼리: + +```sql +EXPLAIN SELECT * +FROM movies IGNORE INDEX (idx_movies_sales_rate) +ORDER BY sales_rate DESC; + +EXPLAIN SELECT * +FROM movies FORCE INDEX (idx_movies_sales_rate) +ORDER BY sales_rate DESC; +``` + +비교 결과: + +| 구분 | type | key | Extra | 해석 | +| --- | --- | --- | --- | --- | +| 인덱스 미사용 | `ALL` | `null` | `Using filesort` | 전체 영화 테이블을 읽은 뒤 예매율 기준 별도 정렬 | +| 인덱스 사용 | `index` | `idx_movies_sales_rate` | `Backward index scan` | 예매율 인덱스를 역방향으로 스캔해 정렬 비용 감소 | + +![movies sales_rate 인덱스 사용](assets/week8-db-optimization/movies-sales-after.png) + +## 6. 한계와 보완점 + +- 이번 적용은 JPA 엔티티의 인덱스 메타데이터 추가 방식이다. 운영 DB에서 `ddl-auto`를 사용하지 않는다면 별도 마이그레이션 SQL이 필요하다. +- 실제 성능 수치는 데이터 크기, 분포, MySQL 옵티마이저 판단에 따라 달라진다. +- 영화 목록의 `release_date` 필터와 `sales_rate` 정렬은 데이터 분포에 따라 하나의 인덱스만으로 완전히 최적화되지 않을 수 있다. 데이터가 충분히 쌓이면 `EXPLAIN ANALYZE` 결과를 보고 복합 인덱스 순서를 다시 조정하는 것이 좋다. +- 좌석 예매 락은 현재 상영 회차 단위 비관적 락이라 안전성은 높지만 인기 상영 회차에서 병목이 될 수 있다. 좌석 단위 선점 테이블 중심으로 락 범위를 줄이는 방식은 다음 단계 개선 후보로 남겼다. + +
+ +
+7주차 캐싱 & 로깅 미션 정리 + +## 1. 캐시 도입 + +### 캐시 적용 대상 + +| 기준 | 질문 | 예시 | +| --- | --- | --- | +| **Read-heavy** | 읽기가 쓰기보다 압도적으로 많은가? | 게시글 상세 조회 | +| **Slow I/O** | DB 조회나 계산 비용이 비싼가? | 복잡한 통계 쿼리, 외부 API | +| **Low Volatility** | 데이터가 자주 바뀌지 않는가? | 공지사항, 상품 카테고리 | +| **Repeatability** | 동일한 요청이 반복적으로 오는가? | 인기 게시글, 메인 피드 | +| **Staleness 허용** | 1~2초 오래된 데이터여도 괜찮은가? | 조회수, 좋아요 수 | + +위 표를 참고해 조회 빈도가 높고 데이터 변경 빈도가 상대적으로 낮은 API를 우선 캐시 대상으로 선택했다. + +- 영화 목록 조회: `/api/v1/movies` +- 영화 상세 조회: `/api/v1/movies/{movieId}` +- 영화관 목록/상세 조회 +- 매점 상품 목록 조회: `/api/concessions/products` +- 이벤트 목록/영화별 이벤트 조회 + +예매 생성, 좌석 조회, 매점 주문, 재고 차감처럼 실시간 상태가 중요한 흐름에는 캐시를 적용하지 않았다. 특히 좌석과 재고는 동시성 제어와 데이터 정합성이 중요하므로 DB의 최신 상태를 기준으로 판단해야 한다. + +### 선택한 캐시 방식 + +- 적용 방식: Spring Cache + Caffeine 로컬 캐시 +- 만료 전략: write 후 10분 만료 +- 크기 제한: 최대 500개 엔트리 +- 무효화 전략: 관리자 생성/삭제 API가 호출되면 관련 캐시 전체 삭제 + +Redis 같은 분산 캐시는 멀티 서버 환경에서 캐시 일관성을 맞추기 좋지만, 현재 프로젝트는 Render 기반 단일 애플리케이션 인스턴스 실습 환경에 가깝다. 따라서 별도 인프라를 추가하지 않고도 효과를 확인할 수 있는 Caffeine 로컬 캐시를 먼저 적용했다. + +강의록에 소개된 방식 중에는 Look-Aside (=Lazy Loading)에 가깝다. + +흐름은 +1. 조회 요청이 들어온다. +2. Spring Cache가 먼저 Caffeine 캐시에 값이 있는지 확인한다. +3. 캐시에 있으면 DB를 조회하지 않고 캐시 값을 반환한다. +4. 캐시에 없으면 Service 메서드가 실행되어 DB를 조회한다. +5. 조회 결과를 캐시에 저장한다. +6. 이후 같은 요청은 캐시에서 반환된다. + +쓰기 쪽은 Write Around에 가까운 방식이다. 관리자 API로 영화, 영화관, 상품, 이벤트 등을 생성하거나 삭제할 때: +DB에 먼저 반영 후 관련 캐시 무효화, 다음 조회 떄 DB에서 다시 읽고 캐시 재적재 과정을 거친다. + +### 캐시 적용 이유 + +- 영화, 영화관, 매점 상품, 이벤트는 사용자가 반복적으로 조회할 가능성이 높다. +- 해당 데이터는 관리자 API를 통해 드물게 변경된다. +- DB 조회를 줄여 단순 조회 API의 응답 시간을 안정화할 수 있다. +- 캐시 무효화 지점이 명확해 과도한 복잡도 없이 적용할 수 있다. + +### 적용한 코드 + +- `CacheConfig`: Caffeine 기반 `CacheManager` 구성 +- `CacheNames`: 캐시 이름 상수화 +- `MovieService`: 영화 목록/상세 조회 캐싱, 영화 생성/삭제 시 캐시 무효화 +- `CinemaService`: 영화관 목록/상세 조회 캐싱, 영화관 생성 시 캐시 무효화 +- `ConcessionService`: 매점 상품 목록 캐싱, 상품 생성 시 캐시 무효화 +- `EventService`: 이벤트 목록/영화별 이벤트 캐싱, 이벤트 생성/연결 시 캐시 무효화 + +## 2. 로그 리팩토링 + +### 개선 방향 + +기존 로그는 예외 처리나 결제 보상 실패처럼 일부 지점에만 남아 있었다. 이번에는 요청 단위 흐름을 추적할 수 있도록 공통 요청 로그를 추가했다. + +### 적용 방식 + +- `RequestLoggingFilter`를 추가해 모든 HTTP 요청 종료 시 로그를 남긴다. +- 요청마다 `requestId`를 생성하거나, 클라이언트가 보낸 `X-Request-Id`를 재사용한다. +- 응답 헤더에도 `X-Request-Id`를 내려주어 클라이언트와 서버 로그를 연결할 수 있게 했다. +- MDC에 `requestId`를 넣고 `logback-spring.xml` 패턴에 출력하도록 했다. +- 로그 파일은 `logs/application.log`에 저장하고, 날짜/크기 기준으로 롤링한다. + +### 로그 형식 + +```text +http_request method=GET uri=/api/v1/movies status=200 durationMs=12 requestId=... +``` + +### 유용한 관찰 지표 + +Grafana/Loki로 로그를 수집한다면 다음 지표를 대시보드에 둘 수 있다. + +- 요청 수: `http_request` 로그 발생량 +- 에러 응답 수: `status=4xx`, `status=5xx` 필터링 +- 느린 요청: `durationMs`가 큰 요청 확인 +- 결제 보상 실패: `외부 결제 취소 보상 처리에 실패` 로그 확인 +- 특정 요청 추적: `requestId`로 요청 흐름 검색 + +### Grafana/Loki 연결 방법 + +처음 Grafana Explore에서 `No logs found`가 나온 이유는 Spring Boot가 `logs/application.log`에는 로그를 쓰고 있었지만, 해당 파일을 Loki로 보내는 수집기가 없었기 때문이다. 이를 해결하기 위해 `monitoring` 디렉터리에 Loki, Alloy, Grafana 구성을 추가했다. + +```text +Spring Boot -> logs/application.log -> Alloy -> Loki -> Grafana +``` + +실행 방법: + +```bash +cd monitoring +docker compose up -d +``` + +상태 확인: + +```bash +curl http://localhost:3101/ready +docker compose ps +``` + +Grafana 접속: + +```text +http://localhost:3001 +admin / admin +``` + +### Grafana 대시보드 구성 결과 + +로컬 Spring Boot 로그를 Loki로 수집하고 Grafana에서 다음 패널을 구성했다. + +- Total Requests: + - 전체 HTTP 요청 수를 확인한다. + - Query: `sum(count_over_time({app="spring-cgv"} |= "http_request" [1m]))` +![img_2.png](img_2.png) + + +- 5xx Server Errors: + - 서버 내부 오류 발생 여부를 확인한다. + - Query: `sum(count_over_time({app="spring-cgv"} |= "http_request" |= "status=5" [5m]))` +![img_6.png](img_6.png) + + +- Requests by Status: + - 2xx, 4xx, 5xx 요청 흐름을 구분해 확인한다. + - 상태 코드는 로그 본문에 포함되므로 LogQL에서는 상태별 문자열 필터를 사용했다. +![img_4.png](img_4.png) +![img_7.png](img_7.png) +![img_8.png](img_8.png) + + +- Payment Compensation Failures: + - 외부 결제 성공 후 내부 처리 실패 시 보상 취소 실패 로그를 추적한다. + - Query: `{app="spring-cgv"} |= "보상 처리에 실패"` +![img_9.png](img_9.png) + + +- Request Logs: + - 실제 요청 로그를 직접 확인한다. + - Query: `{app="spring-cgv"} |= "http_request"` +![img_10.png](img_10.png) + + +이번 모니터링에서는 `http_request` 로그가 Loki에 정상 수집되는 것을 확인했고, Grafana에서 요청 수와 5xx 오류를 시각화했다. 보상 처리 실패 패널은 현재 해당 오류가 발생하지 않아 `No data`로 표시되며, 이는 정상 상태로 판단했다. + + +## 3. Redis / Memcached / 로컬 캐시 비교 + +### Redis + +- 장점: 분산 환경에서 여러 서버가 같은 캐시를 공유할 수 있고, TTL, 자료구조, pub/sub 등을 지원한다. +- 단점: 별도 서버 운영이 필요하고 네트워크 왕복 비용이 있다. +- 적합한 상황: 멀티 서버, 세션 공유, 랭킹, 분산 락, 캐시 일관성이 중요한 서비스 + +### Memcached + +- 장점: 단순 key-value 캐시에 빠르고 가볍다. +- 단점: Redis보다 자료구조와 기능이 제한적이다. +- 적합한 상황: 단순 조회 결과를 빠르게 저장하고 꺼내는 대규모 캐시 + +### Caffeine + +- 장점: 애플리케이션 내부 메모리를 사용해 빠르고, 별도 인프라가 필요 없다. +- 단점: 서버가 여러 대면 인스턴스마다 캐시가 분리되어 일관성 관리가 어렵다. +- 적합한 상황: 단일 서버, 실습 환경, 변경 빈도가 낮은 기준 데이터 캐싱 + +## 4. 한계와 보완점 + +- 현재 캐시는 로컬 메모리 기반이므로 서버가 여러 대로 늘어나면 인스턴스별 캐시 불일치가 생길 수 있다. +- 조회 결과를 Entity 형태로 캐시하고 있어, 규모가 커지면 DTO 캐싱 또는 전용 조회 모델 캐싱을 검토할 수 있다. +- 운영 환경에서는 Actuator/Micrometer와 연동해 캐시 hit/miss, 요청 latency, 에러율을 함께 관찰하는 것이 좋다. +- 감사 로그가 필요한 결제/주문/예매 취소 이벤트는 별도 audit log 저장소로 분리할 수 있다. + +## 5. 검증 내용 + +- 실행 명령: `./gradlew test --no-daemon` +- 결과: `BUILD SUCCESSFUL` +- 캐시 설정, 요청 로그 필터, 기존 서비스 테스트가 함께 컴파일되고 테스트를 통과하는 것을 확인했다. + +### 캐시 성능 측정 + +`CachePerformanceTest`에서 H2 테스트 DB에 영화 30건을 저장한 뒤 `MovieService.getAllMovies()`를 반복 호출해 측정했다. + +- 측정 명령: `./gradlew test --tests com.ceos23.cgv.global.cache.CachePerformanceTest --no-daemon --info` +- Cold 조회 평균: `7.870ms` +- Warm 캐시 조회 평균: `0.017ms` +- 응답 시간 개선율: 약 `99.78%` +- Cache hit count: `100` +- Cache miss count: `2` +- Cache hit rate: `98.04%` + +측정값은 로컬 H2 기반 서비스 계층 반복 호출 결과이므로 운영 DB와 실제 HTTP 네트워크 비용을 포함한 수치는 아니다. 다만 동일 조회가 반복될 때 DB 접근을 캐시 접근으로 대체해 응답 시간이 크게 줄어드는 효과와 캐시 적중 동작은 확인할 수 있었다. + +
+ +
+5주차 배포 미션 정리 + +## 1. 프로젝트 마무리 및 리팩토링 + +### 진행 내용 + +- 예약 생성과 좌석 저장을 하나의 예매 흐름으로 통합했다. +- 같은 상영 회차의 같은 좌석 중복 예매를 막기 위해 `Screening` 비관적 락과 `ReservedSeat` 유니크 제약을 함께 사용했다. +- 예매 결제에는 티켓팅 케이스를 적용해 좌석을 먼저 선점하고, 결제 실패 또는 취소 시 좌석을 복구하도록 했다. +- 매점 결제에는 커머스 케이스를 적용해 결제 성공 후 재고를 차감하도록 했다. +- 운영성 API는 `/api/admin/**` 컨트롤러로 분리해 관리자 권한 규칙을 타도록 정리했다. +- JWT 보안 리뷰를 반영해 인증/인가 실패 응답, refresh token HttpOnly Cookie, JWT secret 환경변수화를 적용했다. + +### 리팩토링 기준 + +- Service는 유스케이스 흐름 조율에 집중한다. +- 도메인 규칙은 도메인 객체 또는 정책 객체로 이동한다. +- DTO 변환은 응답 DTO의 `from` 메서드와 Controller 계층에서 처리한다. +- 예외는 `CustomException`과 `ErrorCode` 기반으로 통일한다. +- 과도한 구조 변경보다 현재 프로젝트 규모에 맞는 점진적 개선을 우선한다. + +### 리팩토링 결과 + +- `Reservation`, `FoodOrder`, `Inventory`에 상태 변경과 검증 메서드를 모아 Service의 조건문을 줄였다. +- `CouponDiscountPolicy`, `ReservationPricePolicy`로 가격/할인 정책을 분리했다. +- `ReservationService`와 `ConcessionService`의 결제 흐름은 DB 트랜잭션과 외부 API 호출을 분리해 락 점유 시간과 상태 불일치 위험을 줄였다. +- `PaymentService`와 `PaymentClient`로 외부 결제 API 호출 책임을 분리했다. +- `GlobalExceptionHandler`, `CustomAuthenticationEntryPoint`, `CustomAccessDeniedHandler`로 API 에러 응답 형식을 맞췄다. +- 매점 주문 총액 계산을 `FoodOrder`와 `OrderItem` 도메인 메서드로 이동해 Service가 계산식을 직접 알지 않도록 했다. +- 매점 재고 차감 시 `productId` 기준으로 정렬한 뒤 비관적 락을 획득해 상품 주문 순서 차이로 발생할 수 있는 데드락 위험을 줄였다. +- 쿠폰 할인 규칙을 `Coupon` enum으로 분리해 쿠폰 코드별 할인 정책을 한 곳에서 관리하도록 했다. + +## 2. Docker 기반 배포 + +### Dockerfile 구성 + +- Build stage: `gradle:8.14-jdk21` +- Runtime stage: `eclipse-temurin:21-jre` +- 빌드 단계에서 `gradle clean bootJar --no-daemon`으로 jar를 생성한다. +- 실행 단계에서는 생성된 jar를 `java -jar app.jar`로 실행한다. + +## 3. 수동 배포 + +### 배포 환경 + +- Platform: Render +- Database: Aiven MySQL +- Runtime: Docker +- 배포 URL: https://spring-cgv-23rd.onrender.com + +### AWS EC2 대신 Render를 사용한 이유 + +기존에는 AWS EC2를 활용한 배포를 고려했지만, 프리티어 사용량이 만료되어 추가 비용이 발생하는 상황이었다. + +또한, 이전에 AWS EC2를 활용해 여러번 배포를 해보았기 때문에 다른 플랫폼 경험도 쌓을겸 해서 여러 방안을 찾아보았다. + +무료 플랜을 제공하는 PaaS 기반 배포 플랫폼을 대안으로 검토하였고, Render, Railway, Fly.io, Oracle Cloud Free Tier 등을 비교하였다. 그중 Render를 선택한 이유는 다음과 같다. + +- 무료 플랜으로도 서비스 배포 실습이 가능하다. +- GitHub 연동 및 CI/CD 구성이 단순하다. +- Docker 기반 배포를 지원해 환경 일관성을 유지할 수 있다. +- 별도의 인프라 설정 없이 빠르게 배포할 수 있다. + +이번 과제의 목적은 배포 경험, CI/CD 구성 등에 있으므로 반드시 AWS를 사용해야 하는 것은 아니라고 판단했다. 최종적으로 Render를 사용하되, 배포 구조는 Docker 이미지 기반으로 구성하여 향후 AWS EC2 환경에서도 동일하게 실행할 수 있도록 했다. + +### 배포 과정 + +1. Dockerfile 작성 +2. Render Web Service 생성 +3. GitHub Repository 연결 +4. Aiven MySQL 생성 +5. Render Environment Variables 등록 +6. 수동 배포 실행 +7. 서버 정상 실행 확인 + +### 환경변수 관리 + +Render에는 다음 환경변수를 등록했다. 실제 값은 Git과 README에 기록하지 않는다. + +- `PORT` +- `DB_HOST` +- `DB_PORT` +- `DB_NAME` +- `DB_USERNAME` +- `DB_PASSWORD` +- `JWT_SECRET` +- `PAYMENT_API_SECRET_KEY` + +민감 정보는 `.env`에 로컬로만 두고, Git에는 올리지 않는다. 현재 `.env`는 `.gitignore`에 포함되어 있으며 Git 추적 대상이 아니다. + +### 배포 확인 + +![img.png](img.png) + +- Render 로그에서 Spring Boot `Started Application` 로그를 확인했다. +- 보호된 API를 인증 없이 호출하면 `401` 응답이 반환된다. +- 이는 서버가 정상 실행 중이고, Spring Security 인증 필터가 보호 API를 정상적으로 막고 있다는 의미이다. + +## 4. CI/CD 자동 배포 + +### 적용 방식 + +- GitHub Actions로 CI를 수행한다. +- Render의 Auto Deploy 옵션은 `After CI Checks Pass`로 설정한다. +- 따라서 테스트 또는 빌드가 실패하면 Render 자동 배포가 진행되지 않는다. + +### 자동 배포 흐름 + +1. `main` 브랜치에 push 또는 PR 생성 +2. GitHub Actions 실행 +3. JDK 21 설정 +4. Gradle cache 적용 +5. `./gradlew test --no-daemon` 실행 +6. `./gradlew clean bootJar --no-daemon` 실행 +7. CI 성공 시 Render가 자동 배포 +8. CI 실패 시 배포 중단 + +### GitHub Actions 구성 + +- Workflow: `.github/workflows/ci.yml` +- Trigger: `main` 브랜치 push, `main` 대상 pull request +- JDK: 21 +- Distribution: Temurin +- Cache: Gradle +- Test: `./gradlew test --no-daemon` +- Build: `./gradlew clean bootJar --no-daemon` + +### 기대 효과 + +- 수동 배포 반복을 줄인다. +- 테스트 실패 코드가 배포되는 것을 막는다. +- 배포 과정의 일관성과 재현성을 높인다. + +## 5. 트러블슈팅 + +### Gradle 버전 문제 + +- 문제: Spring Boot 4.0.3은 Gradle 8.14 이상이 필요했다. +- 해결: Dockerfile의 빌드 이미지를 `gradle:8.7-jdk21`에서 `gradle:8.14-jdk21`로 설정했다. + +### 환경 변수 누락 + +![img_1.png](img_1.png) +- 문제: Render는 로컬 `.env` 파일을 자동으로 읽지 않는다. +- 해결: Render Environment Variables에 환경 변수를 직접 등록했다. + +### host.docker.internal 문제 + +- 문제: 로컬 Docker에서는 `host.docker.internal`로 Mac의 MySQL에 접근할 수 있지만, Render 클라우드 환경에서는 사용할 수 없다. +- 해결: Aiven MySQL 외부 DB를 생성하고 `DB_HOST`, `DB_PORT`를 Aiven 접속 정보로 등록했다. + +## 6. 추가 학습 + +### Bastion Host + +- 개념: private subnet 내부 서버에 직접 접근하지 않고, 제한된 중간 서버를 통해 안전하게 접속하기 위한 서버이다. +- 이번 프로젝트 적용 여부: Render 기반 PaaS 배포와 Aiven 외부 DB를 사용했으므로 직접 적용하지 않았다. + +### Public Subnet / Private Subnet + +- Public Subnet: 인터넷 게이트웨이를 통해 외부에서 접근 가능한 서브넷이다. +- Private Subnet: 외부에서 직접 접근할 수 없고 내부 통신 중심으로 구성하는 서브넷이다. +- 이번 프로젝트 적용 여부: AWS VPC를 직접 구성하지 않았으므로 적용하지 않았다. 다만 EC2로 확장한다면 웹 서버는 public subnet, DB는 private subnet에 두는 구조를 고려할 수 있다. + +### Subnet CIDR + +- 개념: VPC 네트워크를 더 작은 IP 주소 범위로 나누기 위한 표기 방식이다. +- 이번 프로젝트 적용 여부: Render와 Aiven이 네트워크 구성을 관리하므로 직접 설정하지 않았다. + +## 7. 느낀 점 + +이번 미션을 통해 Docker, 외부 DB, 클라우드 배포, CI/CD 흐름을 직접 연결하면서 로컬 환경과 배포 환경의 차이를 이해했다. 특히 로컬의 `.env`, Docker 컨테이너의 네트워크, Render의 환경변수, Aiven MySQL 접속 정보가 각각 다르게 동작한다는 점을 실제 오류를 통해 확인했다. + +또한 GitHub Actions와 Render의 `After CI Checks Pass` 흐름을 연결하면서, 단순히 서버를 한 번 띄우는 것보다 테스트가 통과한 코드만 배포되도록 만드는 과정이 중요하다는 점을 배웠다. + +
+ +
+리팩토링 내역 및 설계 의도 + +## 리팩토링 개요 + +기능 동작과 API 요청/응답 형식을 유지하면서, 서비스에 몰려 있던 유스케이스 흐름과 도메인 규칙을 점진적으로 분리하는 방향으로 진행했습니다. 큰 아키텍처 전환 대신 현재 프로젝트 규모에 맞춰 Entity factory, 도메인 메서드, 정책 클래스, 전역 예외 처리 정리를 적용했습니다. + +## 주요 변경 사항 + +### 1. Service 책임 축소와 유스케이스 흐름 정리 +- 변경 내용: `ReservationService`, `ConcessionService`, `InventoryService`, `PhotoService`, `MovieService`, `CinemaService`, `EventService`, `PersonService`, `UserService` 등에서 조회, 검증, 생성, 저장 흐름을 private 메서드로 분리했습니다. +- 리팩토링 이유: 하나의 Service 메서드에 권한 체크, 검증, 엔티티 생성, 저장, 계산이 함께 있으면 변경 지점이 불명확해지기 때문입니다. +- 기대 효과: Service는 유스케이스 흐름을 조율하고, 세부 규칙은 이름 있는 메서드나 도메인 객체로 이동해 읽기와 변경이 쉬워집니다. + +### 2. 도메인 객체의 생성과 규칙 응집 +- 변경 내용: `Reservation.create`, `FoodOrder.create`, `OrderItem.create`, `Inventory.create`, `Photo.create`, `Movie.create`, `User.create` 등 Entity 정적 factory를 추가했습니다. `Reservation.validateCancelableBy`, `Inventory.changeStockBy`, `Inventory.updateStock`처럼 상태 변경 규칙도 도메인 메서드로 모았습니다. +- 리팩토링 이유: Service에서 builder를 직접 조립하면 생성 기본값과 상태 규칙이 여러 곳으로 퍼질 수 있기 때문입니다. +- 기대 효과: 엔티티 생성 규칙과 상태 변경 조건이 한 곳에 모여, 이후 필드나 정책이 바뀌어도 수정 범위를 좁힐 수 있습니다. + +### 3. 예외 코드 기반 응답 통일 +- 변경 내용: 직접 `IllegalArgumentException`, `IllegalStateException`을 던지던 흐름을 `CustomException`과 `ErrorCode` 기반으로 바꿨습니다. 회원, 예약, 쿠폰, 사진 대상, 지역, 재고 수량 등에 필요한 에러 코드를 추가했습니다. +- 리팩토링 이유: 문자열 예외는 API 응답 형식과 상태 코드를 일관되게 유지하기 어렵고, 클라이언트가 에러를 안정적으로 분기하기 어렵기 때문입니다. +- 기대 효과: 예외 발생 시 도메인별 에러 코드와 HTTP status가 명확해지고, 응답 형식이 전역 핸들러를 통해 통일됩니다. + +### 4. 역직렬화 예외와 전역 예외 처리 보강 +- 변경 내용: `PhotoCreateRequest`, `Region.from`에서 발생하는 `CustomException`이 Jackson 역직렬화 중 `HttpMessageNotReadableException`으로 감싸질 수 있어, `GlobalExceptionHandler`에서 cause chain을 확인해 원래 `CustomException` 응답으로 변환하도록 했습니다. `IllegalArgumentException` 처리도 `INVALID_INPUT_VALUE` 기반 응답으로 통일했습니다. +- 리팩토링 이유: 요청 본문 변환 중 발생한 도메인 예외가 500으로 내려가면 실제 클라이언트 오류를 서버 오류처럼 전달할 수 있기 때문입니다. +- 기대 효과: 잘못된 사진 대상, 지원하지 않는 지역 값 등도 의도한 에러 코드(`PH002`, `RG001`)로 응답할 수 있습니다. + +### 5. 쿠폰 할인 정책 분리 +- 변경 내용: `ReservationService` 안에 있던 `WELCOME_CGV`, `VIP_HALF_PRICE` 할인 규칙을 `CouponDiscountPolicy`로 분리했습니다. +- 리팩토링 이유: 쿠폰 할인은 예약 저장 흐름보다 가격 정책에 가까운 도메인 규칙이므로, Service가 직접 조건문을 계속 갖고 있으면 확장과 테스트가 어려워집니다. +- 기대 효과: 예약 Service는 가격 계산 흐름만 조율하고, 쿠폰 규칙은 작은 순수 정책 객체로 테스트하기 쉬워졌습니다. + +### 6. 매점 주문과 재고 처리 의도 개선 +- 변경 내용: `ConcessionService`에서 상품 Map 로딩, 필수 상품 조회, 재고 차감, 주문 항목 생성을 `loadProductMap`, `getRequiredProduct`, `decreaseInventoryStock`, `createOrderItem`으로 분리했습니다. `Inventory`에서는 재고 부족과 잘못된 재고 수량을 각각 `INVENTORY_SHORTAGE`, `INVALID_STOCK_QUANTITY`로 구분했습니다. +- 리팩토링 이유: 주문 생성은 조회, 검증, 재고 차감, 주문 항목 생성이 섞이기 쉬운 흐름이어서, 단계별 의도를 드러내는 이름이 필요했습니다. +- 기대 효과: 주문 처리 흐름을 따라가기 쉬워지고, 재고 관련 실패 사유도 더 정확히 표현됩니다. + +### 7. Gradle wrapper 복구와 테스트 보정 +- 변경 내용: 누락되어 있던 `gradle/wrapper/gradle-wrapper.jar`를 복구하고 `.gitignore`에서 wrapper jar가 추적되도록 예외를 추가했습니다. `ConcessionServiceTest`, `InventoryServiceTest`, `ReservationServiceTest`는 리팩토링된 서비스 흐름과 에러 코드에 맞게 보정했습니다. +- 리팩토링 이유: wrapper jar가 없으면 `./gradlew test` 실행 자체가 어려워지고, 리팩토링 후 테스트가 실제 서비스 의존성과 맞지 않으면 회귀 검증이 약해지기 때문입니다. +- 기대 효과: 로컬에서 Gradle wrapper 기반 테스트 실행이 가능해졌고, 변경된 도메인 규칙을 테스트가 따라가도록 정리되었습니다. + +## 리팩토링 기준 + +- 기능 동작과 API 요청/응답 형식은 유지했습니다. +- 큰 구조 전환보다 현재 구조 안에서 Service, Entity, Policy, Exception의 책임을 조금씩 분리했습니다. +- 도메인 규칙은 가능하면 도메인 메서드나 정책 클래스로 모았습니다. +- 예외는 가능한 한 `ErrorCode`를 통해 응답 코드와 메시지를 일관되게 관리했습니다. + +
+ +
+결제 시스템 연동 및 티켓팅/커머스 케이스 비교 + +## 결제 시스템 연동 개요 + +CGV 예매는 좌석이라는 한정 자원을 다룬다. 따라서 예매는 결제 완료 후 좌석을 차감하는 커머스 방식보다, 예매 시점에 좌석을 먼저 선점하고 결제 실패나 취소 시 좌석을 복구하는 티켓팅 방식이 적합하다. + +반면 매점 상품 주문은 동일 상품의 수량 재고를 다룬다. 좌석처럼 특정 자원을 결제 전에 점유할 필요가 상대적으로 낮으므로, 결제 성공 후 재고를 차감하는 커머스 방식을 적용했다. + +이번 연동은 CEOS 결제 서버 명세를 기준으로 `POST /payments/{paymentId}/instant` 즉시 결제와 `POST /payments/{paymentId}/cancel` 결제 취소를 사용한다. 외부 결제 API 호출은 DB 트랜잭션 밖에서 수행하고, 내부 예약 상태 변경은 별도 DB 트랜잭션으로 분리한다. + +## 티켓팅 케이스와 커머스 케이스 비교 + +### 티켓팅 케이스 +- 재고/좌석 차감 시점: 결제 전 예매 생성 또는 결제창 진입 시점에 좌석을 선점한다. +- 장점: 같은 좌석을 여러 사용자가 동시에 결제하는 상황을 줄일 수 있다. +- 단점: 결제 실패, 이탈, 취소 시 좌석 복구 로직이 필요하다. +- 적합한 상황: 영화 좌석, 공연 티켓처럼 특정 좌석이 한 번만 판매되어야 하는 경우. + +### 커머스 케이스 +- 재고/재고 차감 시점: 결제 성공 후 상품 재고를 차감한다. +- 장점: 결제 실패 시 재고 복구가 단순하다. +- 단점: 결제 중 재고가 사라질 수 있고, 한정 좌석처럼 개별 자원이 중요한 경우 사용자 경험이 나빠질 수 있다. +- 적합한 상황: 일반 상품처럼 동일한 재고가 여러 개 있고 결제 후 차감해도 되는 경우. + +## 우리 CGV 서비스에 선택한 방식 + +- 예매 선택 방식: 티켓팅 케이스 +- 예매 선택 이유: 같은 상영 회차의 같은 좌석은 중복 예매되면 안 되므로, 결제 전에 좌석을 선점해야 한다. +- 좌석 선점/복구 흐름: `Screening` 비관적 락 조회 후 `Reservation(PENDING)`과 `ReservedSeat`를 저장한다. 결제 실패 또는 취소 시 `Reservation`을 `CANCELED`로 변경하고 `ReservedSeat`를 삭제해 좌석을 복구한다. +- 매점 선택 방식: 커머스 케이스 +- 매점 선택 이유: 매점 상품은 동일 상품의 수량 재고를 다루므로, 결제 성공 후 재고를 차감하면 결제 실패 시 재고 복구가 필요 없어 흐름이 단순하다. + +## HTTP Client 방식 비교 + +### Feign Client +- 장점: 인터페이스 기반 선언형 클라이언트라 외부 API 계약을 표현하기 쉽다. +- 단점: Spring Cloud OpenFeign 의존성과 설정이 추가된다. +- 적합한 상황: 외부 API가 많고 클라이언트 인터페이스를 명확히 분리해야 하는 경우. + +### RestClient 또는 WebClient +- 장점: Spring에서 제공하는 HTTP Client를 직접 사용할 수 있다. `RestClient`는 동기 MVC 서비스에 잘 맞고, `WebClient`는 비동기/논블로킹에 강하다. +- 단점: Feign보다 선언형 인터페이스 느낌은 약하다. `WebClient`는 현재 MVC 구조에는 상대적으로 과하다. +- 적합한 상황: 현재처럼 Spring MVC 기반 서비스에서 외부 API 호출 수가 많지 않은 경우 `RestClient`가 적합하다. + +### 이 프로젝트에서 선택한 방식 +- 선택한 방식: `RestClient` +- 선택 이유: 현재 프로젝트는 `spring-boot-starter-webmvc` 기반이고 OpenFeign 의존성이 없다. 결제 API 수가 많지 않아 별도 의존성을 추가하지 않고 동기 흐름으로 명확히 처리하는 방식이 가장 작다. + +## 결제 API 연동 흐름 + +1. 좌석 선점 + - DB 트랜잭션 1에서 `Screening`을 비관적 락으로 조회한다. + - `Reservation`을 `PENDING` 상태로 저장하고 결제 요청 전에 생성한 `paymentId`를 함께 저장한다. + - `ReservedSeat` 저장으로 좌석을 선점한다. +2. 결제 요청 + - DB 트랜잭션 밖에서 CEOS 결제 서버의 즉시 결제 API를 호출한다. +3. 결제 성공 처리 + - DB 트랜잭션 2에서 `paymentId`로 예약을 다시 조회하고 `PENDING` 상태를 검증한 뒤 `COMPLETED`로 변경한다. +4. 결제 실패 시 좌석 복구 + - DB 트랜잭션으로 예약 상태를 `CANCELED`로 변경하고 선점 좌석을 삭제한다. +5. 결제 취소 시 외부 결제 취소 + 좌석 복구 + - 외부 결제 취소 API가 성공한 뒤 내부 예약을 `CANCELED`로 변경하고 좌석을 삭제한다. + +## 매점 결제 API 연동 흐름 + +1. 주문 생성 + - DB 트랜잭션 1에서 `FoodOrder(PENDING)`와 `OrderItem`을 저장하고 결제 요청 전에 생성한 `paymentId`를 함께 저장한다. + - 이 시점에는 아직 `Inventory`를 차감하지 않는다. +2. 결제 요청 + - DB 트랜잭션 밖에서 CEOS 결제 서버의 즉시 결제 API를 호출한다. +3. 결제 성공 후 재고 차감 + - DB 트랜잭션 2에서 `paymentId`로 주문을 다시 조회한다. + - 주문 상태가 `PENDING`인지 검증한 뒤, 상품별 `Inventory`를 비관적 락으로 조회해 재고를 차감하고 주문을 `COMPLETED`로 변경한다. +4. 결제 실패 처리 + - 외부 결제 요청이 실패하면 주문을 `CANCELED`로 변경한다. 재고는 아직 차감 전이므로 복구할 필요가 없다. +5. 결제 성공 후 재고 차감 실패 + - 결제는 성공했지만 재고 부족 또는 내부 DB 오류로 주문 완료에 실패하면 외부 결제 취소 API를 보상 호출하고 주문을 `CANCELED`로 변경한다. + +## 예외 및 보상 처리 + +- 결제 실패: 외부 즉시 결제 요청이 실패하면 `PAYMENT_FAILED` 예외를 발생시키고, 내부 예약을 `CANCELED`로 바꾸며 좌석을 복구한다. +- 결제 취소 실패: 외부 결제 취소가 실패하면 내부 예약 취소와 좌석 복구는 수행하지 않는다. +- 내부 DB 저장 실패: 외부 결제 성공 후 내부 `COMPLETED` 처리 중 실패하면 외부 결제 취소 API를 호출하고 내부 예약/좌석 취소를 보상 처리한다. +- 매점 결제 실패: 주문만 `CANCELED`로 변경한다. 커머스 흐름에서는 결제 성공 전 재고를 차감하지 않으므로 재고 복구가 필요 없다. +- 매점 재고 부족: 결제 성공 후 재고 차감 단계에서 재고 부족이 확인되면 외부 결제를 취소하고 주문을 `CANCELED`로 변경한다. +- 중복 paymentId: UUID 기반 `reservation-{uuid}` 형식으로 생성한다. 외부 서버에서 중복이 발생하면 결제 실패로 처리한다. +- 중복 좌석 예매: 기존 `PESSIMISTIC_WRITE`와 `ReservedSeat` 유니크 제약을 유지한다. + +## 현재 구조의 한계 + +- 외부 결제 API 호출과 내부 DB 상태 변경은 완전한 분산 트랜잭션이 아니다. +- 서버가 외부 결제 성공 직후 종료되면 내부 상태 보정이 필요할 수 있다. +- 운영 환경에서는 Outbox, 결제 이력 테이블, 재시도 큐, 스케줄러 기반 결제 상태 보정이 필요할 수 있다. + +## 테스트/검증 결과 + +- 실행한 테스트: + - `database=cgv password=dngur1213 PAYMENT_API_SECRET_KEY=placeholder ./gradlew test --tests com.ceos23.cgv.domain.reservation.service.ReservationServiceTest` + - `database=cgv password=dngur1213 PAYMENT_API_SECRET_KEY=placeholder ./gradlew test --tests com.ceos23.cgv.domain.concession.service.ConcessionServiceTest` +- 외부 CEOS 결제 서버는 테스트에서 직접 호출하지 않고 `PaymentService`를 mock 처리했다. + +
+ +
+보안 리뷰 반영 내용 보기 + +## JWT 설정 + +- `jwt.secret`은 설정 파일에 직접 두지 않고 `JWT_SECRET` 환경변수로 주입한다. +- 로컬 실행 시에는 충분히 긴 Base64 인코딩 secret을 `JWT_SECRET`으로 설정해야 한다. +- 실제 secret 값은 저장소와 README에 기록하지 않는다. + +## 인증/인가 실패 응답 + +- 인증 실패는 `401`, 인가 실패는 `403`으로 분리한다. +- 응답 형식은 기존 전역 예외 응답과 맞춰 `status`, `code`, `message`, `errors` 구조를 사용한다. + +## JWT 검증 로그 + +- JWT 검증 실패 시 `warn` 로그를 남긴다. +- 토큰 원문은 민감 정보이므로 로그에 남기지 않고, 예외 타입과 메시지만 기록한다. + +## Authentication credential 처리 + +- JWT는 서명 검증으로 신뢰성을 판단하므로 `UsernamePasswordAuthenticationToken`의 credential에는 토큰 원문 대신 `null`을 넣는다. +- 현재 코드에서는 credential 값을 직접 참조하지 않으므로 동작 영향 없이 토큰 노출 가능성을 줄일 수 있다. + +## Refresh Token Cookie 전환 + +- HttpOnly Cookie 방식은 XSS로 인한 refresh token 탈취 위험을 줄일 수 있다. +- 로그인과 토큰 재발급 응답 body에는 access token만 포함한다. +- refresh token은 `Set-Cookie` 헤더의 `refreshToken` HttpOnly Cookie로 내려준다. +- 토큰 재발급 API는 request body 대신 `refreshToken` Cookie를 읽어 처리한다. +- 현재 로컬 개발 환경은 HTTPS가 아니므로 Cookie `secure` 옵션은 `false`로 둔다. 운영 HTTPS 환경에서는 `secure=true` 적용이 필요하다. + +
+ +
+동시성 문제와 해결 방법 + +## 동시성 문제가 발생할 수 있는 상황 + +Spring은 일반적으로 요청마다 별도 스레드가 처리되는 Thread Per Request 방식으로 동작한다. 따라서 여러 사용자가 같은 상영 회차의 같은 좌석을 동시에 예매하면 같은 공유 자원에 동시에 접근할 수 있다. + +기존 좌석 예매 흐름은 `Reservation` 생성과 `ReservedSeat` 저장이 분리되어 있었다. 이 경우 좌석 저장이 실패하면 좌석 없는 `Reservation`이 남을 수 있고, 동시에 같은 상영 회차 좌석 저장 요청이 들어오면 여러 트랜잭션이 같은 좌석 저장을 시도하는 Race Condition 상황이 발생할 수 있다. + +현재는 `POST /api/reservations` 요청에서 예매 정보와 좌석 목록을 함께 받아, `Reservation` 생성과 `ReservedSeat` 저장을 하나의 트랜잭션으로 처리한다. 좌석 중복이 발생하면 `SEAT_ALREADY_RESERVED` 예외가 발생하고 전체 트랜잭션이 rollback되어 좌석 없는 예약이 남지 않는다. + +## 해결 방법 비교 + +### 1. synchronized +- 개념: JVM 내부에서 특정 코드 블록을 한 번에 하나의 스레드만 실행하도록 막는다. +- 장점: 구현이 단순하고 별도 인프라가 필요 없다. +- 단점: 단일 서버/JVM 안에서만 동작하며, 서버가 여러 대면 중복 요청을 막지 못한다. +- 적합한 상황: 단일 JVM 내부의 짧고 단순한 임계 구역 보호. +- 우리 서비스에 적용하기 어려운 이유 또는 적합성: 예매 좌석은 DB에 저장되는 공유 자원이고, 멀티 서버 확장 가능성을 고려하면 부적합하다. + +### 2. 비관적 락 +- 개념: 트랜잭션이 데이터를 읽을 때 DB row에 락을 걸어 다른 트랜잭션의 동시 수정을 기다리게 한다. +- 장점: 충돌 가능성이 높은 자원을 DB 수준에서 직렬화할 수 있다. +- 단점: 락 대기 시간이 생기며, 락 순서가 꼬이면 Deadlock 위험이 있다. +- 적합한 상황: 같은 좌석, 같은 상영 회차처럼 동시에 접근하면 안 되는 자원이 명확한 경우. +- 우리 서비스에 적합한지: 같은 `Screening`의 좌석 저장 요청을 순서대로 처리하기에 적합하다. + +### 3. 낙관적 락 +- 개념: `@Version` 값으로 수정 충돌을 감지하고, 충돌 시 예외를 발생시킨다. +- 장점: 락 대기 비용이 적고 읽기 많은 환경에 유리하다. +- 단점: 충돌 후 재시도/예외 처리가 필요하고, insert 중복 문제에는 직접적이지 않다. +- 적합한 상황: 같은 엔티티를 자주 읽지만 동시에 수정하는 빈도는 낮은 경우. +- 우리 서비스에 적합한지: 좌석 중복 예매는 `ReservedSeat` 신규 insert 충돌이 핵심이라 `@Version`만으로는 직접 해결하기 어렵다. + +### 4. Redis 분산 락 +- 개념: Redis에 특정 key를 락으로 저장해 여러 서버 간 동시 접근을 제어한다. +- 장점: 멀티 서버 환경에서 좌석 단위 락을 정교하게 걸 수 있다. +- 단점: Redis 인프라, 락 만료, 장애 상황 처리가 필요해 복잡도가 높다. +- 적합한 상황: 트래픽이 크고 애플리케이션 서버가 여러 대인 운영 환경. +- 우리 서비스에 적합한지: 현재 프로젝트 규모에서는 과하다. 추후 멀티 서버와 대규모 트래픽을 고려할 때 후보가 될 수 있다. + +### 5. 유니크 제약 조건 +- 개념: DB에서 특정 컬럼 조합의 중복 저장을 금지한다. +- 장점: 멀티 서버 환경에서도 DB가 최종적으로 중복 데이터를 막는다. +- 단점: 충돌을 사전에 막기보다 저장 시점에 예외로 감지한다. +- 적합한 상황: 같은 상영 회차의 같은 좌석처럼 절대 중복되면 안 되는 데이터. +- 우리 서비스에 적합한지: 이미 `ReservedSeat`에 적용되어 있으며, 반드시 유지해야 하는 최종 방어선이다. + +## CGV 서비스에 선택한 해결 방법 + +선택한 방법은 **DB 유니크 제약 조건 유지 + 상영 회차 row에 비관적 락 적용**이다. + +선택 근거는 다음과 같다. +- 현재 프로젝트는 Spring/JPA 기반이므로 `@Lock(LockModeType.PESSIMISTIC_WRITE)`를 Repository에 적용하는 방식이 가장 작다. +- 같은 상영 회차의 같은 좌석 중복 예매를 막는 것이 핵심이므로, 좌석 저장 전에 해당 `Screening` row를 잠그면 같은 회차 좌석 저장 흐름을 직렬화할 수 있다. +- 기존 `(screening_id, seat_row, seat_col)` 유니크 제약은 유지해 DB 최종 방어선을 남긴다. +- Redis 분산 락은 현재 규모에는 복잡하고, `synchronized`는 멀티 서버에서 동작하지 않는다. +- 낙관적 락은 기존 row 업데이트 충돌 감지에는 좋지만, 현재 문제처럼 좌석 row insert 중복을 직접 막는 데는 유니크 제약과 비관적 락 조합보다 덜 적합하다. + +## 적용 내용 + +- `ScreeningRepository.findByIdForUpdate()`를 추가하고 `PESSIMISTIC_WRITE` 락을 적용했다. +- `ReservationService.createReservation()`에서 `Screening`을 비관적 락으로 조회하고, `Reservation` 생성과 `ReservedSeat` 저장을 하나의 트랜잭션으로 묶었다. +- `ReservationCreateRequest`에 좌석 목록을 추가해 예매 확정 API에서 좌석까지 함께 받도록 변경했다. +- `ReservedSeat`의 유니크 제약과 `DataIntegrityViolationException`을 `SEAT_ALREADY_RESERVED`로 변환하는 기존 방어 로직은 유지했다. + +예매 생성 요청 예시: + +```json +{ + "screeningId": 1, + "peopleCount": 2, + "payment": "APP_CARD", + "couponCode": "WELCOME_CGV", + "seats": [ + { "row": "G", "col": 4 }, + { "row": "G", "col": 5 } + ] +} +``` + +## 검증 내용 + +- `database=cgv password=dngur1213 ./gradlew test --tests com.ceos23.cgv.domain.reservation.service.ReservationServiceTest` +- `database=cgv password=dngur1213 ./gradlew test --tests com.ceos23.cgv.domain.reservation.service.ReservedSeatServiceTest` +- `database=cgv password=dngur1213 ./gradlew test` + +동시성 통합 테스트는 별도 테스트 DB가 분리되어 있지 않고 현재 테스트가 로컬 MySQL 환경변수에 의존하므로, 이번 변경에서는 서비스 단위 테스트와 전체 테스트로 회귀 여부를 확인했다. + +
+ +
+이전 README 내용 + +# spring-cgv-23rd +CEOS 23기 백엔드 스터디 - CGV 클론 코딩 프로젝트 + +
+❓EntityManager는 누가 생성하고, DB와의 연결은 어떻게 이루어질까요? + +## EntityManager는 누가 생성할까? + +직접 `new EntityManager()`로 만드는 게 아니라, **JPA 구현체(예: Hibernate)** 가 생성한다. +조금 더 정확히 말하면, **EntityManagerFactory**가 EntityManager를 생성한다. + +```java +EntityManagerFactory emf = Persistence.createEntityManagerFactory("unitName"); +EntityManager em = emf.createEntityManager(); +``` + +### 스프링에서는 누가 관리할까? + +Spring 환경에서는**Spring Framework** 가 대신 해준다. + +- `@PersistenceContext` +- 또는 `@Autowired` + +```java +@PersistenceContext +private EntityManager em; +``` + +내부적으로는 + +- Spring이 **EntityManagerFactory를 생성** +- 요청마다 적절한 **EntityManager를 주입** +- 트랜잭션 범위에 맞게 관리 + +### DB 연결은 어떻게 이루어질까? + +👇 전체 흐름 + +1. 애플리케이션 시작 +2. Spring → DataSource 생성 +3. DataSource 기반으로 EntityManagerFactory 생성 +4. 요청 시 EntityManager 생성 +5. 트랜잭션 시작 시 DB 커넥션 획득 + +이 때, EntityManager가 항상 DB와 연결되어 있는 건 아님. + +실제 DB 연결은 **트랜잭션 시작 시점**에 이루어짐 + +```java +@Transactional +public void save() { + em.persist(entity); // 이 시점에 커넥션 사용 +} +``` + +### 정리 + +- EntityManager 생성 → **EntityManagerFactory** +- 관리 → **Spring** +- DB 연결 → **DataSource + 트랜잭션 시점** + +## ❓flush의 발생하는 시점은 언제일까요? + +### 트랜잭션 commit 시 + +- 트랜잭션이 커밋되기 직전에 자동으로 flush 발생 +- 거의 모든 경우 기본 동작 + +```java +@Transactional +public void save() { + em.persist(member); +} // commit 시 flush 발생 +``` + +### `em.flush()` 직접 호출 + +- 개발자가 강제로 DB에 반영 + +```java +em.persist(member); +em.flush(); // 즉시 SQL 실행 +``` + +#### 특징 + +- commit 안 해도 SQL 실행됨 +- 하지만 rollback 되면 결국 반영 안 됨 + +### JPQL 쿼리 실행 직전 + +- 기본 flush 모드는 **AUTO** +- JPQL 실행 전에 flush 발생 + +```java +em.persist(member); + +em.createQuery("select m from Member m") + .getResultList(); // 여기서 flush 발생 +``` + +#### 이유 + +- JPQL은 DB 기준으로 조회하기 때문에 +- 영속성 컨텍스트와 DB 결과를 맞추기 위해 flush + +### 중요한 추가 포인트 + +#### flush ≠ commit + +- flush → SQL 실행 +- commit → 실제 DB 반영 확정 + +#### flush는 영속성 컨텍스트를 비우지 않는다 + +- 1차 캐시는 그대로 유지됨 +- 단지 DB에 반영만 하는 것 + +#### flush 모드도 존재 + +- AUTO (기본) +- COMMIT + +```java +em.setFlushMode(FlushModeType.COMMIT); +``` + +COMMIT 모드 특징 + +- JPQL 실행 시 flush 안 함 +- 오직 commit 시에만 flush + +### 정리 + +flush는 + +**"DB와 영속성 컨텍스트의 동기화를 맞추기 위해 필요한 시점에 발생"** + +- commit 직전 +- 직접 호출 +- JPQL 실행 직전 (AUTO 모드) + +## **JOIN을 사용할 때 SQL과 JPQL이 어떤 기준으로 조인을 수행하는지** 비교해보면 차이를 더 쉽게 이해할 수 있어요 + +### SQL JOIN 기준 + +테이블과 컬럼(FK)을 기준으로 조인 + +```java +SELECT * +FROM member m +JOIN team t ON m.team_id = t.id; +``` + +특징 + +- 개발자가 직접 `ON` 조건 작성 +- **외래키(FK) 컬럼 기반으로 조인** +- DB 구조(테이블, 컬럼)에 의존 + +### JPQL JOIN 기준 + +엔티티와 연관관계를 기준으로 조인 + +```java +SELECT m FROM Member m JOIN m.team t +``` + +특징 + +- `ON` 조건을 직접 쓰지 않음 (기본적으로) +- **객체의 연관관계 필드 기준 (`m.team`)** +- 내부적으로 Hibernate 가 SQL로 변환 + +| 구분 | 기준 | +| --- | --- | +| SQL | 테이블 + 외래키 컬럼 | +| JPQL | 엔티티 + 연관관계 필드 | + +👆사고방식 자체가 다르다 + +- SQL → **데이터 중심 (테이블)** +- JPQL → **객체 중심 (엔티티 그래프)** + +### 정리 + +SQL은 외래키 컬럼 기준으로 조인하고, + +JPQL은 엔티티 간 연관관계 필드를 기준으로 조인한다. + +## ❓fetch join을 사용하면서 페이징을 적용할 때 발생하는 문제에 대해 알아보아요! + +### 상황: fetch join + 페이징 + +```java +SELECT m FROM Member m +JOIN FETCH m.orders +``` + +여기서 페이징을 적용하면: + +```java +query.setFirstResult(0); +query.setMaxResults(10); +``` + +### 문제 발생 원인 + +핵심은 **1:N 관계에서의 fetch join** 이다. + +```java +Member A - Order 1, 2, 3 +Member B - Order 4, 5 +``` + +fetch join 결과: + +```java +A, Order1 +A, Order2 +A, Order3 +B, Order4 +B, Order5 +``` + +row가 뻥튀기됨 (중복 발생) + +### 페이징이 깨지는 이유 + +DB는 이렇게 생각함 + +→ "row 기준으로 10개 잘라야지” + +하지만 우리는 + +→ "Member 10명"을 기대 + +### 실제 문제 + +- DB 페이징 → row 기준 +- JPA 결과 → 중복 제거 후 엔티티 기준 + +결과적으로: + +- 데이터 누락 발생 +- 페이지 크기 깨짐 +- 심하면 메모리 페이징 발생 + +### 특히 위험한 경우 + +컬렉션 fetch join (`@OneToMany`) + +- JPA 구현체(예: Hibernate)는 + + → 경고 로그 발생 + +```java +firstResult/maxResults specified with collection fetch; applying in memory! +``` + +의미: + +- DB에서 페이징 안 함 +- 전부 가져온 뒤 메모리에서 자름 (🔥 성능 최악) + +### 해결 방법 + +#### 1. 컬렉션 fetch join + 페이징 ❌ + +→ 가장 중요한 원칙 + +#### 2. ToOne 관계만 fetch join + 페이징 ✅ + +```java +SELECT m FROM Member m +JOIN FETCH m.team +``` + +이유 + +- row 증가 없음 + +#### 3. 컬렉션은 별도 조회 (지연로딩 활용) + +```java +SELECT m FROM Member m +``` + +이후 + +```java +m.getOrders() // 필요할 때 조회 +``` + +#### 4. DTO 조회 방식 (실무에서 많이 사용) + +```java +SELECT new com.example.dto.MemberDto(m.id, o.name) +FROM Member m +JOIN m.orders o +``` + +#### 5. 배치 사이즈 활용 + +```java +@BatchSize(size = 100) +``` + +→ N+1 문제 완화 + +### 정리 + +컬렉션 fetch join을 사용하면 row가 증가하기 때문에 + +DB 페이징이 깨지고, 경우에 따라 메모리 페이징이 발생한다. + +## data jpa를 찾다보면 SimpleJpaRepository에서 entity manager를 생성자 주입을 통해서 주입 받는다. 근데 싱글톤 객체는 한번만 할당을 받는데, 한번 연결 때 마다 생성이 되는 entity manager를 생성자 주입을 통해서 받는 것은 수상하지 않는가? 어떻게 되는 것일까? 한번 알아보자 + +### SimpleJpaRepository는 싱글톤인데 EntityManager는 왜 괜찮을까? + +**주입되는 건 진짜 EntityManager가 아니라 프록시(EntityManager Proxy)이다.** + +Spring Data JPA의 `SimpleJpaRepository`는 싱글톤이다. + +```java +public class SimpleJpaRepository { + private final EntityManager em; +} +``` + +여기서 주입되는 `em`은 실제 객체가 아니라 + +**→ Spring Framework 가 만든 프록시** + +### 핵심 동작 방식 + +- 주입 시점 → 프록시 객체 1개 (싱글톤처럼 보임) +- 실제 사용 시 → 트랜잭션마다 진짜 EntityManager 할당 + +### 내부 흐름 + +```java +클라이언트 요청 + → 트랜잭션 시작 + → 실제 EntityManager 생성 + → 프록시가 실제 EM을 찾아 위임 +``` + +### 정리 + +싱글톤에 주입되는 건 프록시이고, + +실제 EntityManager는 트랜잭션마다 따로 생성된다. + +## fetch join 할 때 distinct를 안하면 생길 수 있는 문제 + +### 문제 상황 + +```java +SELECT m FROM Member m +JOIN FETCH m.orders +``` + +결과: + +```java +Member A +Member A +Member A +Member B +``` + +### 문제 + +- 동일 엔티티 중복 반환 +- 컬렉션 크기 이상하게 보임 +- 로직 오류 발생 가능 + +### 해결 + +```java +SELECT DISTINCT m FROM Member m +JOIN FETCH m.orders +``` + +### 디테일 + +JPQL의 DISTINCT는 2가지 역할 + +1. SQL에 DISTINCT 추가 +2. **JPA가 엔티티 중복 제거 (핵심)** + +### 정리 + +fetch join 시 DISTINCT를 사용하지 않으면 + +중복 엔티티가 반환된다. + +## fetch join 을 할 때 생기는 에러가 생기는 3가지 에러 메시지의 원인과 해결 방안 + +### `HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!` + +#### 원인 + +- 컬렉션 fetch join + 페이징 + +#### 결과 + +- DB 페이징 불가 +- 메모리 페이징 발생 (성능 최악) + +#### 해결 + +- 컬렉션 fetch join 제거 +- ToOne만 fetch join +- 또는 DTO 조회 + +### `query specified join fetching, but the owner of the fetched association was not present in the select list` + +#### 원인 + +```java +SELECT o FROM Order o +JOIN FETCH o.member m +``` + +여기서 m을 fetch 했는데 select에 없음 → 문제 발생 + +#### 해결 + +반드시 fetch 대상의 owner 포함 + +```java +SELECT o FROM Order o +JOIN FETCH o.member +``` + +또는 + +```java +SELECT m FROM Member m +JOIN FETCH m.orders +``` + +### `org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags` + +#### 원인 + +```java +JOIN FETCH m.orders +JOIN FETCH m.coupons +``` + +둘 다 List (bag)일 때 발생 + +#### 왜? + +DB 결과가 카테시안 곱으로 폭발 + +```java +orders 3개 * coupons 4개 = 12 rows +``` + +Hibernate가 감당 못함 (Hibernate 정책적으로 막음) + +#### 해결 방법 + +방법 1: Set으로 변경 + +```java +Set orders; +``` + +방법 2: 하나만 fetch join + +방법 3: 나머지는 지연로딩 + BatchSize + +### 정리 + +| 문제 | 원인 | 해결 | +| --- | --- | --- | +| 페이징 에러 | 컬렉션 fetch join | fetch join 제거 | +| owner 없음 | select 대상 누락 | owner 포함 | +| multiple bag | List 2개 fetch | Set / 분리 조회 | + +
+ +## 1️⃣ DB를 모델링해봐요! + +### CGV 서비스 개요 + +CGV는 영화 관람을 중심으로 다양한 기능을 제공하는 복합 플랫폼입니다. + +핵심 기능은 다음과 같이 크게 5가지 도메인으로 나눌 수 있습니다. + +- **영화 도메인**: 영화 정보 조회, 배우 정보, 리뷰 +- **상영 도메인**: 영화 상영 스케줄 및 상영관 관리 +- **예매 도메인**: 좌석 선택 및 영화 예매 +- **커머스 도메인**: 매점 상품 주문 및 재고 관리 +- **커뮤니티 도메인**: 리뷰, 시네톡, 좋아요 기능 + +이러한 기능들을 기반으로 실제 서비스 흐름을 분석한 뒤, 데이터 간 관계를 중심으로 ERD를 설계하였습니다. + +### ERD 설계 핵심 방향 + +본 ERD는 단순 데이터 저장이 아니라 **실제 서비스 동작 방식**을 반영하는 것을 목표로 설계되었습니다. + +핵심 설계 기준은 다음과 같습니다: + +- 다대다 관계는 반드시 중간 테이블로 분리 +- 조회 성능과 확장성을 고려한 테이블 분리 +- 실제 사용자 행동 흐름(예매, 주문 등)을 기준으로 모델링 +- 불필요한 데이터 생성 최소화 (ex. 좌석 테이블 제거) + +### 모델링 설명 + +1. 좌석 테이블을 만들지 않기 + - “통로가 없고 빈 곳이 없는 직사각형”, “동일한 타입이면 좌석 형태가 같다” + - 즉, 일반관(10x10), 특별관(15x15) 처럼 규격이 고정되어 있으므로, 굳이 DB에 모든 좌석 데이터를 100개, 200개씩 미리 만들어둘 필요가 없다. + - 예매 된 좌석만 DB에 저장하고, 화면에 보여줄 때는 전체 좌석 화면에서 예매 된 좌석만 색깔을 칠하는 방식이 효율적. +2. 매점 상품과 지점별 재고를 분리 (다대다 관계 해결) + - 메뉴는 전국 공통이므로 하나만 만든다. + - 각 지점의 재고는 ‘영화관’과 ‘상품’ 사이의 중간 테이블로 만들어 관리한다. +3. 중복 예매 방지를 위해 예매된 좌석 상세 테이블 만들기 + - 상영관 ID, 좌석 행, 좌석 열 3개를 묶어서 유니크 키 제약조건 설정하기 +4. 매점 구매 시 환불 X + - 매점 구매 내역 테이블에서 상태 컬럼 생략 또는 무조건 ‘COMPLETED’ +5. 영화 - 배우 관계를 다대다로 분리 + - 한 배우는 여러 영화에 출연할 수 있고, 하나의 영화에도 여러 배우가 출연한다. + - 또한 단순 출연이 아니라 **역할(주연, 조연, 감독 등)** 이 존재한다. + + 따라서 `movies` ↔ `persons` 를 직접 연결하지 않고 중간 테이블 `work_participations`로 분리한다. + + - `movie_id`, `actor_id`를 FK로 가지며 + - `role` 컬럼으로 역할 정보까지 함께 관리 +6. 영화 좋아요 / 영화관 좋아요 분리 + - `movie_likes`, `cinema_likes` 테이블을 별도로 둔다. + + 이유: + + - 좋아요 대상이 서로 다름 (영화 vs 영화관) + - 확장성 고려 (추후 리뷰 좋아요 등 추가 가능) +7. 상영 정보와 영화 분리 + - 영화 자체 정보(`movies`)와 + + 실제 상영 정보(`screenings`)는 완전히 다른 개념 + + `movies` + + → 영화 메타데이터 (제목, 러닝타임 등) + + `screenings` + + → 실제 상영 스케줄 (시간, 상영관) + +8. 상영관(theater)과 영화관(cinema) 구조 분리 + - 하나의 영화관(`cinemas`)에는 여러 상영관(`theaters`)이 존재 + + `cinemas (1) : theaters (N)` + + - 좌석 크기, 타입(일반관, 특별관)은 상영관 기준으로 관리 + + → 이 구조 덕분에 + + 같은 영화관인데 IMAX관, 일반관 다르게 운영 가능 + +9. 예매(reservations)와 좌석(reserved_seats) 분리 + - 하나의 예매에는 여러 좌석이 포함될 수 있음 + + `reservations (1) : reserved_seats (N)` + + - 좌석은 따로 테이블을 만들지 않고 + + **예매된 좌석만 저장** + + 추가 핵심: + + - `(screening_id, seat_row, seat_col)` → UNIQUE + + 이걸로 **중복 좌석 예매 방지** + +10. 주문(food_orders) - 주문상품(order_items) 구조 + - 하나의 주문에는 여러 상품이 포함될 수 있음 + + `food_orders (1) : order_items (N)` + + - `order_items`에서 수량(`quantity`) 관리 + + 장점: + + - 같은 상품 여러 개 주문 가능 + - 주문 단위와 상품 단위 책임 분리 +11. 유저 활동 데이터 분리 (user_statics) + - 유저의 활동 통계는 별도 테이블로 분리 + + 이유: + + - 조회 성능 최적화 (count 쿼리 최소화) + - 랭킹 / 프로필 빠른 조회 가능 + + 예: + + - cinetalk_count + - follower_count + + → 실시간 계산이 아니라 **집계 데이터 캐싱 개념** + +12. 리뷰 / 시네톡 분리 + - `reviews`: 영화에 대한 평가 중심 콘텐츠 + - `cinetalks`: 커뮤니티 성격 (영화 + 영화관 기반) + + 둘 다 user, movie를 참조하지만 목적이 다름 + + → 하나로 합치지 않고 분리한 이유: + + - 기능 확장 시 충돌 방지 + - 정책 다르게 적용 가능 (ex. 신고, 노출 방식) +13. 이벤트와 영화 연결 (movie_events) + - 하나의 이벤트는 여러 영화에 적용될 수 있음 + - 하나의 영화도 여러 이벤트에 포함될 수 있음 + + `events ↔ movies` = N:M 관계 + + → `movie_events`로 분리 + +14. 사진(photo) 테이블의 유연한 구조 + - `actor_id`, `movie_id` 둘 다 nullable + + 하나의 테이블로: + + - 배우 사진 + - 영화 스틸컷 + + 을 모두 관리 + + → 테이블 분리 대신 **유연한 단일 테이블 전략** + +15. ENUM 적극 활용 + + 여러 테이블에서 ENUM 사용: + + - 영화 장르 (`genre`) + - 관 타입 (`type`) + - 결제 방식 (`payment`) + - 리뷰 타입 (`type`) 등 + + 👉 장점: + + - 데이터 정합성 보장 + - 잘못된 값 입력 방지 + + 👉 단점: + + - 확장 시 마이그레이션 필요 (트레이드오프) + +image + +https://www.erdcloud.com/d/PhXPysc9AfrTJbSYq + +
+

1️⃣ JWT 인증(Authentication) 방법에 대해서 알아보기

+
+ +
+ +
+JWT를 이용한 인증 방식(액세스토큰, 리프레쉬 토큰)에 대해서 조사해보아요 +
+ +- **개념**: 사용자를 인증하고 식별하기 위한 정보를 암호화시킨 토큰입니다. 별도의 세션 저장소 없이 토큰 자체로 검증이 가능하여 Stateless한 현대 웹에서 널리 쓰입니다. +- **구조**: `Header`(타입 및 알고리즘) + `Payload`(클레임 정보, 유저 ID, 만료일시 등) + `Signature`(위변조 검증용 서명) + +Image + +- **토큰의 종류**: + - **Access Token**: 실제 서버 자원을 요청할 때 헤더에 실어 보내는 수명이 짧은 토큰입니다. + - **Refresh Token**: Access Token이 만료되었을 때, 새 Access Token을 발급받기 위한 수명이 긴 토큰입니다. (보안을 위해 주로 DB에 저장) + +**🔄 JWT 인증 흐름** +1. **로그인**: 사용자가 로그인을 요청하면 서버가 회원 DB를 대조하여 확인합니다. +2. **토큰 발급**: 유효기간이 짧은 `Access Token`과 긴 `Refresh Token`을 함께 생성하여 응답합니다. +3. **API 요청**: 클라이언트는 매 API 요청 시 헤더에 `Access Token`을 담아 보냅니다. +4. **만료 및 재발급**: `Access Token`이 만료되어 401(Unauthorized) 에러를 받으면, 보관해둔 `Refresh Token`을 서버로 보내 유효성 검증 후 새로운 `Access Token`을 발급받아 통신을 재개합니다. + +Image + +**✅ 장단점** +- **장점**: 세션 저장소가 필요 없어 서버 자원을 절약하고 확장에 유리합니다. 짧은 수명의 Access Token과 긴 수명의 Refresh Token을 조합해 보안과 사용자 편의성(자동 로그인 유지)을 모두 챙길 수 있습니다. +- **단점**: 한 번 발급된 토큰은 임의로 강제 만료시키기 어렵고, Payload 자체는 누구나 디코딩할 수 있어 민감한 정보를 담을 수 없습니다. + +
+
+ +
+ +
+추가로 세션, 쿠키, OAuth 등 다른 방식도 조사해보아요 +
+ +**🍪 쿠키(Cookie) 인증** +- **특징**: 브라우저에 저장되는 Key-Value 형태의 문자열입니다. 한 번 설정되면 이후 매 요청마다 브라우저가 자동으로 헤더에 담아 보냅니다. +- **한계**: 용량이 4KB로 제한적이며, 네트워크 상에 값이 그대로 노출되어 보안에 매우 취약합니다. + +**🗄️ 세션(Session) 인증** +- **특징**: 비밀번호 같은 민감한 정보는 서버(메모리/DB)에 저장하고, 클라이언트에게는 출입증 역할의 무의미한 고유 식별자(`Session ID`)만 쿠키에 담아 발급합니다. +- **장/단점**: 쿠키 자체에 유의미한 개인정보가 없어 훨씬 안전하지만, 동시 접속자가 많아질수록 서버의 저장 공간과 메모리 부하가 심해집니다. 또한 '세션 하이재킹' 공격의 위험이 있습니다. + +**🔐 OAuth 2.0 인증** +- **특징**: 사용자가 비밀번호를 우리 서비스에 직접 제공하지 않고, 구글/카카오 같은 외부의 신뢰할 수 있는 서비스의 인증 및 권한을 위임받아 사용하는 범용 프로토콜입니다. +- **장점**: 사용자는 일일이 회원가입을 할 필요 없이 안전하게 서비스를 이용할 수 있으며, 서비스 개발자는 복잡한 보안 처리를 거대 플랫폼에 위임할 수 있어 더욱 안전합니다. + +
+
+ +
+
+ +
+

2️⃣ 액세스 토큰 발급 및 검증 로직 구현하기

+
+ +
+ +- **`TokenProvider` 구현**: `io.jsonwebtoken` 라이브러리를 활용하여 Access 및 Refresh Token 생성, 서명(Signature) 검증 로직을 구현했습니다. +- **DB 조회 최소화 (최적화)**: `getAuthentication` 호출 시 매번 DB를 찌르지 않고, **토큰의 Payload에 담긴 유저 식별자(ID)와 권한 정보(Role)만을 이용해 Authentication 객체를 생성**하도록 최적화하여 Stateless한 JWT의 장점을 극대화했습니다. +- **`JwtAuthenticationFilter` 적용**: 헤더(`Authorization`)에서 토큰을 추출해 유효성을 검증한 뒤, 정상 토큰인 경우 `SecurityContextHolder`에 인증 정보를 저장하는 커스텀 필터를 구현했습니다. + +
+
+ +
+

3️⃣ 회원가입 및 로그인 API 구현하고 테스트하기

+
+ +
+ +- **비밀번호 암호화**: 회원가입 API 호출 시 `BCryptPasswordEncoder`를 통해 평문 비밀번호를 단방향 암호화하여 DB에 안전하게 저장합니다. +- **로그인 및 토큰 발급**: 로그인 시 `AuthenticationManager`를 통해 계정을 검증하고, 성공 시 `TokenProvider`를 거쳐 Access Token과 Refresh Token을 동시 발급합니다. + Image + +
+
+ +
+

4️⃣ 토큰이 필요한 API 1개 이상 구현하고 테스트하기

+
+ +
+ +- **URL 접근 권한 제어**: 관리자(ADMIN)와 일반 사용자(USER)의 권한(`Role`)을 Enum으로 분리하고, `SecurityConfig`를 통해 `/api/admin/**` 등 경로별 접근 권한을 제어했습니다. + Image +- **API 보안 개선**: 기존에 Request 파라미터나 바디로 직접 유저 ID를 입력받던 취약한 구조를 개선했습니다. `ReservationController`(예매, 예매 취소) 등에서 `@AuthenticationPrincipal`을 사용해 **검증된 JWT 토큰에서 안전하게 유저 식별자를 추출**해 비즈니스 로직을 처리하도록 리팩토링했습니다. + Image + +
+
+ +
+

5️⃣(도전 미션~!) 리프레쉬 토큰 발급 로직 구현하고 테스트하기

+
+ +
+ +보안과 사용자 편의성을 모두 잡기 위해 Refresh Token 시스템을 도입했습니다. +- **DB 저장**: 로그인 시 발급된 긴 수명의 Refresh Token을 DB(`User` 엔티티)에 저장합니다. +- **재발급(Reissue) API 구현**: 토큰이 만료되었을 때 클라이언트가 Refresh Token을 보내면, DB에 저장된 토큰과 대조하여 일치할 경우에만 새로운 토큰을 발급합니다. +- **RTR (Refresh Token Rotation) 기법 적용**: 토큰 재발급 시 Access Token뿐만 아니라 **Refresh Token도 함께 새로 발급하고 DB를 갱신**합니다. 이를 통해 Refresh Token이 탈취되더라도 한 번 사용되면 폐기되도록 보안을 한층 강화했습니다. + Image + +
+
+ +
diff --git a/assets/week8-db-optimization/food-orders-after.png b/assets/week8-db-optimization/food-orders-after.png new file mode 100644 index 00000000..bb64152b Binary files /dev/null and b/assets/week8-db-optimization/food-orders-after.png differ diff --git a/assets/week8-db-optimization/food-orders-before.png b/assets/week8-db-optimization/food-orders-before.png new file mode 100644 index 00000000..585931d8 Binary files /dev/null and b/assets/week8-db-optimization/food-orders-before.png differ diff --git a/assets/week8-db-optimization/inventories-after.png b/assets/week8-db-optimization/inventories-after.png new file mode 100644 index 00000000..2fb800b1 Binary files /dev/null and b/assets/week8-db-optimization/inventories-after.png differ diff --git a/assets/week8-db-optimization/inventories-before.png b/assets/week8-db-optimization/inventories-before.png new file mode 100644 index 00000000..d8d88af6 Binary files /dev/null and b/assets/week8-db-optimization/inventories-before.png differ diff --git a/assets/week8-db-optimization/movies-release-after.png b/assets/week8-db-optimization/movies-release-after.png new file mode 100644 index 00000000..048275fd Binary files /dev/null and b/assets/week8-db-optimization/movies-release-after.png differ diff --git a/assets/week8-db-optimization/movies-release-before.png b/assets/week8-db-optimization/movies-release-before.png new file mode 100644 index 00000000..27b1c7a2 Binary files /dev/null and b/assets/week8-db-optimization/movies-release-before.png differ diff --git a/assets/week8-db-optimization/movies-sales-after.png b/assets/week8-db-optimization/movies-sales-after.png new file mode 100644 index 00000000..f61a3d7b Binary files /dev/null and b/assets/week8-db-optimization/movies-sales-after.png differ diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..1f7801df --- /dev/null +++ b/build.gradle @@ -0,0 +1,52 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '4.0.3' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.ceos23' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-webmvc' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-cache' + implementation 'com.github.ben-manes.caffeine:caffeine' + + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'com.mysql:mysql-connector-j' + testRuntimeOnly 'com.h2database:h2' + + implementation 'org.springframework.boot:spring-boot-starter-validation' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' +} + +tasks.named('test') { + useJUnitPlatform() + + // Java 21+ Mockito Agent 경고 숨기기 + jvmArgs("-XX:+EnableDynamicAgentLoading") +} diff --git a/cgv-reservation-load-test.js b/cgv-reservation-load-test.js new file mode 100644 index 00000000..a76b8a61 --- /dev/null +++ b/cgv-reservation-load-test.js @@ -0,0 +1,140 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate } from 'k6/metrics'; + +export const reservationCreated = new Rate('reservation_created'); +export const expectedPaymentFailure = new Rate('expected_payment_failure'); +export const seatConflict = new Rate('seat_conflict'); + +export const options = { + scenarios: { + reservation_ramp: { + executor: 'ramping-vus', + stages: [ + { duration: '5s', target: Number(__ENV.STAGE1_VUS || 1) }, + { duration: '2m', target: Number(__ENV.STAGE2_VUS || 30) }, + { duration: '2m', target: Number(__ENV.STAGE3_VUS || 50) }, + { duration: '1m', target: 0 }, + ], + gracefulRampDown: '20s', + }, + }, + thresholds: { + http_req_duration: ['p(95)<2000'], + // CEOS 결제 서버가 확률적으로 실패할 수 있으므로, 실제 분석에서는 R006 비율을 따로 보기 + http_req_failed: ['rate<0.20'], + reservation_created: ['rate>0.70'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +const SCREENING_ID = Number(__ENV.SCREENING_ID || 1); +const PAYMENT = __ENV.PAYMENT || 'KAKAO_PAY'; +const COUPON_CODE = __ENV.COUPON_CODE || null; +const SEAT_MODE = __ENV.SEAT_MODE || 'unique'; // unique | contention +const SEAT_POOL_SIZE = Number(__ENV.SEAT_POOL_SIZE || 1); +const SEAT_OFFSET = Number(__ENV.SEAT_OFFSET || Math.floor(Math.random() * 1000000)); + +function jsonHeaders(extra = {}) { + return { headers: { 'Content-Type': 'application/json', ...extra } }; +} + +function safeJson(res) { + try { + return res.json(); + } catch (_) { + return {}; + } +} + +function makeSeat() { + if (SEAT_MODE === 'contention') { + return `A${(__ITER % SEAT_POOL_SIZE) + 1}`; + } + + return `A${SEAT_OFFSET + ((__VU - 1) * 100000) + __ITER + 1}`; +} + +export function setup() { + let email = __ENV.EMAIL; + let password = __ENV.PASSWORD || 'Password1234!'; + + if (__ENV.SIGNUP === 'true') { + const suffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}`; + email = email || `k6-${suffix}@example.com`; + const signupPayload = JSON.stringify({ + name: `k6-user-${suffix}`, + email, + nickname: `k6-${suffix}`, + password, + }); + + const signupRes = http.post(`${BASE_URL}/api/auth/signup`, signupPayload, jsonHeaders()); + check(signupRes, { + 'signup 201 or duplicate 409': (r) => r.status === 201 || r.status === 409, + }); + } + + if (!email) { + throw new Error('EMAIL env가 필요합니다. 새 계정을 만들려면 SIGNUP=true를 함께 지정하세요.'); + } + + const loginRes = http.post( + `${BASE_URL}/api/auth/login`, + JSON.stringify({ email, password }), + jsonHeaders(), + ); + + check(loginRes, { 'login status is 200': (r) => r.status === 200 }); + const body = safeJson(loginRes); + const accessToken = body?.data?.accessToken; + + if (!accessToken) { + throw new Error(`로그인 실패: status=${loginRes.status}, body=${loginRes.body}`); + } + + return { accessToken }; +} + +export default function (data) { + const seat = makeSeat(); + const payload = JSON.stringify({ + screeningId: SCREENING_ID, + payment: PAYMENT, + couponCode: COUPON_CODE, + seatNumbers: [seat], + }); + + if (__ITER < 3 && __VU === 1) { + console.log(`payload=${payload}`); + } + + const res = http.post( + `${BASE_URL}/api/reservations`, + payload, + jsonHeaders({ Authorization: `Bearer ${data.accessToken}` }), + ); + + if (res.status !== 201) { + console.log(`status=${res.status}, body=${res.body}`); + } + + const body = safeJson(res); + const code = body?.code; + + const created = res.status === 201; + const paymentFailed = res.status === 502 && code === 'R006'; + const duplicatedSeat = res.status === 409 && code === 'R002'; + + reservationCreated.add(created); + expectedPaymentFailure.add(paymentFailed); + seatConflict.add(duplicatedSeat); + + check(res, { + 'reservation created': () => created, + 'payment failure is classified as R006': () => !paymentFailed || code === 'R006', + 'seat conflict is classified as R002': () => !duplicatedSeat || code === 'R002', + }); + + sleep(Number(__ENV.THINK_TIME || 1)); +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..61285a65 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..37f78a6a --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..adff685a --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..c4bdd3ab --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/img.png b/img.png new file mode 100644 index 00000000..bd3ffd26 Binary files /dev/null and b/img.png differ diff --git a/img_1.png b/img_1.png new file mode 100644 index 00000000..66ba7aac Binary files /dev/null and b/img_1.png differ diff --git a/img_10.png b/img_10.png new file mode 100644 index 00000000..76cd33a5 Binary files /dev/null and b/img_10.png differ diff --git a/img_2.png b/img_2.png new file mode 100644 index 00000000..8c05b474 Binary files /dev/null and b/img_2.png differ diff --git a/img_3.png b/img_3.png new file mode 100644 index 00000000..911c1e84 Binary files /dev/null and b/img_3.png differ diff --git a/img_4.png b/img_4.png new file mode 100644 index 00000000..5559d47e Binary files /dev/null and b/img_4.png differ diff --git a/img_5.png b/img_5.png new file mode 100644 index 00000000..5559d47e Binary files /dev/null and b/img_5.png differ diff --git a/img_6.png b/img_6.png new file mode 100644 index 00000000..911c1e84 Binary files /dev/null and b/img_6.png differ diff --git a/img_7.png b/img_7.png new file mode 100644 index 00000000..4f3baaa7 Binary files /dev/null and b/img_7.png differ diff --git a/img_8.png b/img_8.png new file mode 100644 index 00000000..c56a3c5e Binary files /dev/null and b/img_8.png differ diff --git a/img_9.png b/img_9.png new file mode 100644 index 00000000..34146ca1 Binary files /dev/null and b/img_9.png differ diff --git a/monitoring/alloy-config.alloy b/monitoring/alloy-config.alloy new file mode 100644 index 00000000..bc6c35e2 --- /dev/null +++ b/monitoring/alloy-config.alloy @@ -0,0 +1,40 @@ +loki.process "spring_boot" { + forward_to = [loki.write.default.receiver] + + stage.regex { + expression = "^(?P\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3})" + } + + stage.regex { + expression = "\\[.*?\\]\\s+(?P(TRACE|DEBUG|INFO|WARN|ERROR))" + } + + stage.labels { + values = { + level = null, + } + } + + stage.timestamp { + source = "timestamp" + format = "2006-01-02 15:04:05.000" + location = "Asia/Seoul" + } +} + +loki.source.file "spring_boot" { + targets = [{ + __address__ = "localhost", + __path__ = "/var/log/spring-boot/application.log", + app = "spring-cgv", + job = "spring-boot", + }] + + forward_to = [loki.process.spring_boot.receiver] +} + +loki.write "default" { + endpoint { + url = "http://loki:3100/loki/api/v1/push" + } +} diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml new file mode 100644 index 00000000..403ad87f --- /dev/null +++ b/monitoring/docker-compose.yml @@ -0,0 +1,57 @@ +services: + loki: + image: grafana/loki:2.9.8 + container_name: spring-cgv-loki + command: -config.file=/etc/loki/local-config.yml + ports: + - "3101:3100" + volumes: + - ./loki-config.yml:/etc/loki/local-config.yml:ro + - loki-data:/loki + networks: + - monitoring + + alloy: + image: grafana/alloy:latest + container_name: spring-cgv-alloy + command: + - run + - --server.http.listen-addr=0.0.0.0:12345 + - --storage.path=/var/lib/alloy/data + - /etc/alloy/config.alloy + ports: + - "12346:12345" + volumes: + - ./alloy-config.alloy:/etc/alloy/config.alloy:ro + - ../logs:/var/log/spring-boot:ro + - alloy-data:/var/lib/alloy/data + depends_on: + - loki + networks: + - monitoring + + grafana: + image: grafana/grafana:10.4.2 + container_name: spring-cgv-grafana + ports: + - "3001:3000" + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_USERS_ALLOW_SIGN_UP=false + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + depends_on: + - loki + networks: + - monitoring + +networks: + monitoring: + driver: bridge + +volumes: + loki-data: + alloy-data: + grafana-data: diff --git a/monitoring/grafana/provisioning/datasources/loki.yml b/monitoring/grafana/provisioning/datasources/loki.yml new file mode 100644 index 00000000..021704ad --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/loki.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + isDefault: true + editable: true diff --git a/monitoring/loki-config.yml b/monitoring/loki-config.yml new file mode 100644 index 00000000..3cf50008 --- /dev/null +++ b/monitoring/loki-config.yml @@ -0,0 +1,34 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + +common: + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2020-10-24 + store: boltdb-shipper + object_store: filesystem + schema: v11 + index: + prefix: index_ + period: 24h + +limits_config: + retention_period: 168h + +compactor: + working_directory: /loki/compactor + shared_store: filesystem + retention_enabled: true diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..ca39d588 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'cgv' diff --git a/src/main/java/com/ceos23/cgv/Application.java b/src/main/java/com/ceos23/cgv/Application.java new file mode 100644 index 00000000..8135d31c --- /dev/null +++ b/src/main/java/com/ceos23/cgv/Application.java @@ -0,0 +1,15 @@ +package com.ceos23.cgv; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + +} diff --git a/src/main/java/com/ceos23/cgv/domain/auth/controller/AuthController.java b/src/main/java/com/ceos23/cgv/domain/auth/controller/AuthController.java new file mode 100644 index 00000000..cb268dd9 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/auth/controller/AuthController.java @@ -0,0 +1,62 @@ +package com.ceos23.cgv.domain.auth.controller; + +import com.ceos23.cgv.domain.auth.dto.*; +import com.ceos23.cgv.domain.auth.service.AuthService; +import com.ceos23.cgv.global.common.dto.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +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/auth") +@RequiredArgsConstructor +public class AuthController { + + private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken"; + + private final AuthService authService; + + // 1. 회원가입 (BCryptPasswordEncoder로 비밀번호 암호화 후 저장) + @PostMapping("/signup") + public ResponseEntity> signup(@Valid @RequestBody SignupRequest request) { + UserResponse response = authService.signup(request); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.created(response)); + } + + // 2. 로그인 (인증 후 Token 발급) + @PostMapping("/login") + public ResponseEntity> login(@Valid @RequestBody LoginRequest request) { + TokenPair tokenPair = authService.login(request); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, createRefreshTokenCookie(tokenPair).toString()) + .body(ApiResponse.success(tokenPair.toResponse())); + } + + // 3. 토큰 재발급 (Access Token이 만료되었을 때 호출) + @PostMapping("/reissue") + public ResponseEntity> reissue( + @CookieValue(name = REFRESH_TOKEN_COOKIE_NAME, required = false) String refreshToken) { + TokenPair tokenPair = authService.reissue(refreshToken); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, createRefreshTokenCookie(tokenPair).toString()) + .body(ApiResponse.success(tokenPair.toResponse())); + } + + private ResponseCookie createRefreshTokenCookie(TokenPair tokenPair) { + return ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, tokenPair.refreshToken()) + .httpOnly(true) + .secure(false) + .path("/") + .maxAge(tokenPair.refreshTokenMaxAgeSeconds()) + .sameSite("Lax") + .build(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/auth/dto/LoginRequest.java b/src/main/java/com/ceos23/cgv/domain/auth/dto/LoginRequest.java new file mode 100644 index 00000000..4949c2df --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/auth/dto/LoginRequest.java @@ -0,0 +1,14 @@ +package com.ceos23.cgv.domain.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이어야 합니다.") + String email, + + @NotBlank(message = "비밀번호는 필수입니다.") + String password +) { +} diff --git a/src/main/java/com/ceos23/cgv/domain/auth/dto/SignupRequest.java b/src/main/java/com/ceos23/cgv/domain/auth/dto/SignupRequest.java new file mode 100644 index 00000000..1d5a8b18 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/auth/dto/SignupRequest.java @@ -0,0 +1,20 @@ +package com.ceos23.cgv.domain.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record SignupRequest( + @NotBlank(message = "이름은 필수입니다.") + String name, + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이어야 합니다.") + String email, + + @NotBlank(message = "닉네임은 필수입니다.") + String nickname, + + @NotBlank(message = "비밀번호는 필수입니다.") + String password +) { +} diff --git a/src/main/java/com/ceos23/cgv/domain/auth/dto/TokenPair.java b/src/main/java/com/ceos23/cgv/domain/auth/dto/TokenPair.java new file mode 100644 index 00000000..6bde4f91 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/auth/dto/TokenPair.java @@ -0,0 +1,11 @@ +package com.ceos23.cgv.domain.auth.dto; + +public record TokenPair( + String accessToken, + String refreshToken, + long refreshTokenMaxAgeSeconds +) { + public TokenResponse toResponse() { + return new TokenResponse(accessToken); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/auth/dto/TokenResponse.java b/src/main/java/com/ceos23/cgv/domain/auth/dto/TokenResponse.java new file mode 100644 index 00000000..4eee9cfe --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/auth/dto/TokenResponse.java @@ -0,0 +1,6 @@ +package com.ceos23.cgv.domain.auth.dto; + +public record TokenResponse( + String accessToken +) { +} diff --git a/src/main/java/com/ceos23/cgv/domain/auth/dto/UserResponse.java b/src/main/java/com/ceos23/cgv/domain/auth/dto/UserResponse.java new file mode 100644 index 00000000..a02e27aa --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/auth/dto/UserResponse.java @@ -0,0 +1,8 @@ +package com.ceos23.cgv.domain.auth.dto; + +public record UserResponse( + Long userId, + String email, + String nickname +) { +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/auth/service/AuthService.java b/src/main/java/com/ceos23/cgv/domain/auth/service/AuthService.java new file mode 100644 index 00000000..5aad0559 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/auth/service/AuthService.java @@ -0,0 +1,135 @@ +package com.ceos23.cgv.domain.auth.service; + +import com.ceos23.cgv.domain.auth.dto.*; +import com.ceos23.cgv.domain.user.entity.User; +import com.ceos23.cgv.domain.user.repository.UserRepository; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import com.ceos23.cgv.global.security.TokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final AuthenticationManager authenticationManager; + private final TokenProvider tokenProvider; + + /** + * 회원가입 로직 + */ + @Transactional + public UserResponse signup(SignupRequest request) { + validateSignupRequest(request); + + User savedUser = userRepository.save(createUser(request)); + + return new UserResponse(savedUser.getId(), savedUser.getEmail(), savedUser.getNickname()); + } + + private void validateSignupRequest(SignupRequest request) { + if (userRepository.existsByEmail(request.email())) { + throw new CustomException(ErrorCode.EMAIL_ALREADY_EXISTS); + } + + if (userRepository.existsByNickname(request.nickname())) { + throw new CustomException(ErrorCode.NICKNAME_ALREADY_EXISTS); + } + } + + private User createUser(SignupRequest request) { + return User.create( + request.name(), + request.email(), + request.nickname(), + passwordEncoder.encode(request.password()) + ); + } + + /** + * 로그인 로직 + */ + @Transactional + public TokenPair login(LoginRequest request) { + Authentication authentication = authenticate(request.email(), request.password()); + Long userId = Long.parseLong(authentication.getName()); + + TokenPair tokenPair = issueTokens(userId, authentication); + User user = findUser(userId); + user.updateRefreshToken(tokenPair.refreshToken()); + + return tokenPair; + } + + /** + * 토큰 재발급 로직 + */ + @Transactional + public TokenPair reissue(String refreshToken) { + validateRefreshToken(refreshToken); + Long userId = Long.parseLong(tokenProvider.getTokenUserId(refreshToken)); + User user = findUser(userId); + validateStoredRefreshToken(user, refreshToken); + + Authentication authentication = createAuthentication(user); + TokenPair tokenPair = issueTokens(userId, authentication); + user.updateRefreshToken(tokenPair.refreshToken()); + + return tokenPair; + } + + private Authentication authenticate(String email, String password) { + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(email, password); + + return authenticationManager.authenticate(authenticationToken); + } + + private User findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + } + + private void validateRefreshToken(String refreshToken) { + if (refreshToken == null || refreshToken.isBlank()) { + throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN); + } + + if (!tokenProvider.validateAccessToken(refreshToken)) { + throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN); + } + } + + private void validateStoredRefreshToken(User user, String refreshToken) { + if (!refreshToken.equals(user.getRefreshToken())) { + throw new CustomException(ErrorCode.REFRESH_TOKEN_MISMATCH); + } + } + + private Authentication createAuthentication(User user) { + return new UsernamePasswordAuthenticationToken( + String.valueOf(user.getId()), + "", + Collections.singleton(new SimpleGrantedAuthority(user.getRole().name())) + ); + } + + private TokenPair issueTokens(Long userId, Authentication authentication) { + String accessToken = tokenProvider.createAccessToken(userId, authentication); + String refreshToken = tokenProvider.createRefreshToken(userId); + + return new TokenPair(accessToken, refreshToken, tokenProvider.getRefreshTokenValidityInSeconds()); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/controller/CinemaController.java b/src/main/java/com/ceos23/cgv/domain/cinema/controller/CinemaController.java new file mode 100644 index 00000000..f26a574c --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/controller/CinemaController.java @@ -0,0 +1,46 @@ +package com.ceos23.cgv.domain.cinema.controller; + +import com.ceos23.cgv.domain.cinema.dto.CinemaResponse; +import com.ceos23.cgv.domain.cinema.dto.TheaterResponse; +import com.ceos23.cgv.domain.cinema.entity.Cinema; +import com.ceos23.cgv.domain.cinema.entity.Theater; +import com.ceos23.cgv.domain.cinema.service.CinemaService; +import com.ceos23.cgv.global.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/cinemas") +@RequiredArgsConstructor +@Tag(name = "Cinema API", description = "영화관(지점) 및 상영관 조회 API") +public class CinemaController { + + private final CinemaService cinemaService; + + @GetMapping + @Operation(summary = "전체 영화관 목록 조회", description = "CGV의 모든 지점 목록을 조회합니다.") + public ResponseEntity>> getAllCinemas() { + List cinemas = cinemaService.getAllCinema(); + return ResponseEntity.ok(ApiResponse.success(cinemas, CinemaResponse::from)); + } + + @GetMapping("/{cinemaId}") + @Operation(summary = "특정 영화관 단건 조회", description = "영화관 ID를 통해 특정 지점의 상세 정보를 조회합니다.") + public ResponseEntity> getCinemaById(@PathVariable Long cinemaId) { + Cinema cinema = cinemaService.getCinemaDetails(cinemaId); + return ResponseEntity.ok(ApiResponse.success(CinemaResponse.from(cinema))); + } + + @GetMapping("/{cinemaId}/theaters") + @Operation(summary = "특정 영화관의 상영관 목록 조회", description = "영화관 ID를 통해 해당 지점에 속한 모든 상영관(1관, IMAX관 등) 목록을 조회합니다.") + public ResponseEntity>> getTheatersByCinemaId(@PathVariable Long cinemaId) { + List theaters = cinemaService.getTheatersByCinemaId(cinemaId); + return ResponseEntity.ok(ApiResponse.success(theaters, TheaterResponse::from)); + } + +} diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/controller/CinemaLikeController.java b/src/main/java/com/ceos23/cgv/domain/cinema/controller/CinemaLikeController.java new file mode 100644 index 00000000..12acf30d --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/controller/CinemaLikeController.java @@ -0,0 +1,39 @@ +package com.ceos23.cgv.domain.cinema.controller; + +import com.ceos23.cgv.domain.cinema.dto.CinemaLikeResponse; +import com.ceos23.cgv.domain.cinema.service.CinemaLikeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/cinema-likes") +@RequiredArgsConstructor +@Tag(name = "Cinema Like API", description = "자주 가는 극장(찜하기) 등록/취소 및 조회 API") +public class CinemaLikeController { + + private final CinemaLikeService cinemaLikeService; + + @PostMapping("/{cinemaId}") + @Operation(summary = "자주 가는 극장 토글", description = "특정 극장을 자주 가는 극장으로 등록하거나 이미 등록되어 있다면 취소합니다.") + public ResponseEntity toggleLike( + @PathVariable Long cinemaId, + @RequestParam Long userId) { // 임시로 쿼리 파라미터로 유저 ID 받음 + String resultMessage = cinemaLikeService.toggleCinemaLike(userId, cinemaId); + return ResponseEntity.ok(resultMessage); + } + + @GetMapping("/user/{userId}") + @Operation(summary = "내가 찜한 극장 목록 조회", description = "유저 ID를 통해 해당 유저가 자주 가는 극장으로 등록한 목록을 가져옵니다.") + public ResponseEntity> getLikedCinemas(@PathVariable Long userId) { + List responses = cinemaLikeService.getLikedCinemasByUser(userId).stream() + .map(CinemaLikeResponse::from) + .collect(Collectors.toList()); + return ResponseEntity.ok(responses); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/controller/ReviewController.java b/src/main/java/com/ceos23/cgv/domain/cinema/controller/ReviewController.java new file mode 100644 index 00000000..0607bcf9 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/controller/ReviewController.java @@ -0,0 +1,41 @@ +package com.ceos23.cgv.domain.cinema.controller; + +import com.ceos23.cgv.domain.cinema.dto.ReviewCreateRequest; +import com.ceos23.cgv.domain.cinema.dto.ReviewResponse; +import com.ceos23.cgv.domain.cinema.entity.Review; +import com.ceos23.cgv.domain.cinema.service.ReviewService; +import com.ceos23.cgv.global.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/reviews") +@RequiredArgsConstructor +@Tag(name = "Review API", description = "영화 실관람평(리뷰) 작성 및 조회 API") +public class ReviewController { + + private final ReviewService reviewService; + + @PostMapping + @Operation(summary = "관람평 작성", description = "특정 영화에 대한 관람평과 관람한 상영관 타입(IMAX 등)을 기록합니다.") + public ResponseEntity> createReview(@RequestBody ReviewCreateRequest request) { + Review review = reviewService.createReview(request); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.created(ReviewResponse.from(review))); // created 메서드로 감싸서 반환 + } + + @GetMapping("/movie/{movieId}") + @Operation(summary = "특정 영화의 관람평 조회", description = "영화 ID를 통해 해당 영화에 작성된 모든 관람평을 조회합니다.") + public ResponseEntity>> getReviewsByMovie(@PathVariable Long movieId) { + List reviews = reviewService.getReviewsByMovieId(movieId); + + return ResponseEntity.ok(ApiResponse.success(reviews, ReviewResponse::from)); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/controller/admin/AdminCinemaController.java b/src/main/java/com/ceos23/cgv/domain/cinema/controller/admin/AdminCinemaController.java new file mode 100644 index 00000000..bdcef080 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/controller/admin/AdminCinemaController.java @@ -0,0 +1,51 @@ +package com.ceos23.cgv.domain.cinema.controller.admin; + +import com.ceos23.cgv.domain.cinema.dto.CinemaCreateRequest; +import com.ceos23.cgv.domain.cinema.dto.CinemaResponse; +import com.ceos23.cgv.domain.cinema.dto.TheaterCreateRequest; +import com.ceos23.cgv.domain.cinema.dto.TheaterResponse; +import com.ceos23.cgv.domain.cinema.entity.Cinema; +import com.ceos23.cgv.domain.cinema.entity.Theater; +import com.ceos23.cgv.domain.cinema.service.CinemaService; +import com.ceos23.cgv.global.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/admin/cinemas") +@RequiredArgsConstructor +@Tag(name = "Admin Cinema API", description = "관리자 전용 영화관 및 상영관 관리 API") +public class AdminCinemaController { + + private final CinemaService cinemaService; + + @PostMapping + @Operation(summary = "영화관(지점) 생성", description = "새로운 CGV 지점(예: 강남점)을 등록합니다.") + public ResponseEntity> createCinema(@RequestBody CinemaCreateRequest request) { + Cinema createdCinema = cinemaService.createCinema( + request.name(), + request.region() + ); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.created(CinemaResponse.from(createdCinema))); + } + + @PostMapping("/{cinemaId}/theaters") + @Operation(summary = "상영관 생성", description = "특정 영화관 지점에 새로운 상영관(예: 1관, IMAX관)을 등록합니다.") + public ResponseEntity> createTheater( + @PathVariable Long cinemaId, + @RequestBody TheaterCreateRequest request) { + + Theater createdTheater = cinemaService.createTheater(cinemaId, request); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.created(TheaterResponse.from(createdTheater))); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/dto/CinemaCreateRequest.java b/src/main/java/com/ceos23/cgv/domain/cinema/dto/CinemaCreateRequest.java new file mode 100644 index 00000000..0c51ff6d --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/dto/CinemaCreateRequest.java @@ -0,0 +1,9 @@ +package com.ceos23.cgv.domain.cinema.dto; + +import com.ceos23.cgv.domain.cinema.enums.Region; + +public record CinemaCreateRequest( + String name, + Region region +) { +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/dto/CinemaLikeResponse.java b/src/main/java/com/ceos23/cgv/domain/cinema/dto/CinemaLikeResponse.java new file mode 100644 index 00000000..8f63441a --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/dto/CinemaLikeResponse.java @@ -0,0 +1,17 @@ +package com.ceos23.cgv.domain.cinema.dto; + +import com.ceos23.cgv.domain.cinema.entity.CinemaLike; + +public record CinemaLikeResponse( + Long likeId, + Long cinemaId, + String cinemaName +) { + public static CinemaLikeResponse from(CinemaLike cinemaLike) { + return new CinemaLikeResponse( + cinemaLike.getId(), + cinemaLike.getCinema().getId(), + cinemaLike.getCinema().getName() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/dto/CinemaResponse.java b/src/main/java/com/ceos23/cgv/domain/cinema/dto/CinemaResponse.java new file mode 100644 index 00000000..fec6a79f --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/dto/CinemaResponse.java @@ -0,0 +1,18 @@ +package com.ceos23.cgv.domain.cinema.dto; + +import com.ceos23.cgv.domain.cinema.entity.Cinema; +import com.ceos23.cgv.domain.cinema.enums.Region; + +public record CinemaResponse( + Long id, + String name, + Region region +) { + public static CinemaResponse from(Cinema cinema) { + return new CinemaResponse( + cinema.getId(), + cinema.getName(), + cinema.getRegion() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/dto/ReviewCreateRequest.java b/src/main/java/com/ceos23/cgv/domain/cinema/dto/ReviewCreateRequest.java new file mode 100644 index 00000000..83f5d4a1 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/dto/ReviewCreateRequest.java @@ -0,0 +1,11 @@ +package com.ceos23.cgv.domain.cinema.dto; + +import com.ceos23.cgv.domain.cinema.enums.TheaterType; + +public record ReviewCreateRequest( + Long userId, + Long movieId, + TheaterType type, + String content +) { +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/dto/ReviewResponse.java b/src/main/java/com/ceos23/cgv/domain/cinema/dto/ReviewResponse.java new file mode 100644 index 00000000..e570415b --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/dto/ReviewResponse.java @@ -0,0 +1,27 @@ +package com.ceos23.cgv.domain.cinema.dto; + +import com.ceos23.cgv.domain.cinema.entity.Review; +import com.ceos23.cgv.domain.cinema.enums.TheaterType; +import java.time.LocalDateTime; + +public record ReviewResponse( + Long reviewId, + String authorName, + String movieTitle, + TheaterType theaterType, + String content, + int likeCount, + LocalDateTime createdAt +) { + public static ReviewResponse from(Review review) { + return new ReviewResponse( + review.getId(), + review.getUser().getNickname(), + review.getMovie().getTitle(), + review.getType(), + review.getContent(), + review.getLikeCount(), + review.getCreatedAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/dto/TheaterCreateRequest.java b/src/main/java/com/ceos23/cgv/domain/cinema/dto/TheaterCreateRequest.java new file mode 100644 index 00000000..06272d92 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/dto/TheaterCreateRequest.java @@ -0,0 +1,11 @@ +package com.ceos23.cgv.domain.cinema.dto; + +import com.ceos23.cgv.domain.cinema.enums.TheaterType; + +public record TheaterCreateRequest( + String name, + TheaterType type, + String maxRow, + int maxCol +) { +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/dto/TheaterResponse.java b/src/main/java/com/ceos23/cgv/domain/cinema/dto/TheaterResponse.java new file mode 100644 index 00000000..4336a8b3 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/dto/TheaterResponse.java @@ -0,0 +1,22 @@ +package com.ceos23.cgv.domain.cinema.dto; + +import com.ceos23.cgv.domain.cinema.entity.Theater; +import com.ceos23.cgv.domain.cinema.enums.TheaterType; + +public record TheaterResponse( + Long id, + String name, + TheaterType type, + String maxRow, + int maxCol +) { + public static TheaterResponse from(Theater theater) { + return new TheaterResponse( + theater.getId(), + theater.getName(), + theater.getType(), + theater.getMaxRow(), + theater.getMaxCol() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/entity/Cinema.java b/src/main/java/com/ceos23/cgv/domain/cinema/entity/Cinema.java new file mode 100644 index 00000000..c276faff --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/entity/Cinema.java @@ -0,0 +1,33 @@ +package com.ceos23.cgv.domain.cinema.entity; + +import com.ceos23.cgv.domain.cinema.enums.Region; +import com.ceos23.cgv.global.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "cinemas") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Cinema extends BaseTimeEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "cinema_id") + private Long id; + + @Column(nullable = false, length = 50) + private String name; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private Region region; + + public static Cinema create(String name, Region region) { + return Cinema.builder() + .name(name) + .region(region) + .build(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/entity/CinemaLike.java b/src/main/java/com/ceos23/cgv/domain/cinema/entity/CinemaLike.java new file mode 100644 index 00000000..b28be9a6 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/entity/CinemaLike.java @@ -0,0 +1,33 @@ +package com.ceos23.cgv.domain.cinema.entity; + +import com.ceos23.cgv.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "cinema_likes") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class CinemaLike { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "cinema_like_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cinema_id", nullable = false) + private Cinema cinema; + + public static CinemaLike create(User user, Cinema cinema) { + return CinemaLike.builder() + .user(user) + .cinema(cinema) + .build(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/entity/Review.java b/src/main/java/com/ceos23/cgv/domain/cinema/entity/Review.java new file mode 100644 index 00000000..fd067863 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/entity/Review.java @@ -0,0 +1,49 @@ +package com.ceos23.cgv.domain.cinema.entity; + +import com.ceos23.cgv.domain.cinema.enums.TheaterType; +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.domain.user.entity.User; +import com.ceos23.cgv.global.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "reviews") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Review extends BaseTimeEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "review_id") + private Long id; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private TheaterType type; // 상영관 종류 + + @Column(nullable = false) + private int likeCount; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "movie_id", nullable = false) + private Movie movie; + + public static Review create(User user, Movie movie, TheaterType type, String content) { + return Review.builder() + .user(user) + .movie(movie) + .type(type) + .content(content) + .likeCount(0) + .build(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/entity/Theater.java b/src/main/java/com/ceos23/cgv/domain/cinema/entity/Theater.java new file mode 100644 index 00000000..15be3ba0 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/entity/Theater.java @@ -0,0 +1,46 @@ +package com.ceos23.cgv.domain.cinema.entity; + +import com.ceos23.cgv.domain.cinema.enums.TheaterType; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "theaters") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Theater { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "theater_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cinema_id", nullable = false) + private Cinema cinema; + + @Column(nullable = false, length = 50) + private String name; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private TheaterType type; + + // seatCount(총 좌석 수)를 지우고 직사각형의 끝점(행/열)을 명시합니다. + @Column(name = "max_row", nullable = false, length = 2) + private String maxRow; // 예: "J" (A~J행까지 존재) + + @Column(name = "max_col", nullable = false) + private int maxCol; // 예: 12 (1~12열까지 존재) + + public static Theater create(Cinema cinema, String name, TheaterType type, String maxRow, int maxCol) { + return Theater.builder() + .cinema(cinema) + .name(name) + .type(type) + .maxRow(maxRow) + .maxCol(maxCol) + .build(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/enums/Region.java b/src/main/java/com/ceos23/cgv/domain/cinema/enums/Region.java new file mode 100644 index 00000000..7d5f63c1 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/enums/Region.java @@ -0,0 +1,37 @@ +package com.ceos23.cgv.domain.cinema.enums; + +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import com.fasterxml.jackson.annotation.JsonCreator; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Region { + SEOUL("서울"), + GYEONGGI("경기"), + INCHEON("인천"), + GANGWON("강원"), + DAEJEON("대전"), + CHUNGCHEONG("충청"), + DAEGU("대구"), + BUSAN("부산"), + ULSAN("울산"), + GYEONGSANG("경상"), + GWANGJU("광주"), + JEOLLA("전라"), + JEJU("제주"); + + private final String description; + + @JsonCreator + public static Region from(String value) { + for (Region region : Region.values()) { + if (region.getDescription().equals(value) || region.name().equals(value)) { + return region; + } + } + throw new CustomException(ErrorCode.UNSUPPORTED_REGION); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/enums/TheaterType.java b/src/main/java/com/ceos23/cgv/domain/cinema/enums/TheaterType.java new file mode 100644 index 00000000..19ea7ece --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/enums/TheaterType.java @@ -0,0 +1,17 @@ +package com.ceos23.cgv.domain.cinema.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TheaterType { + NORMAL(15000, "일반관"), + IMAX(20000, "IMAX관"), + FOUR_DX(20000, "4DX관"), + SCREEN_X(20000, "SCREENX관"), + SUITE(20000, "스위트관"); + + private final int basePrice; + private final String description; +} diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/repository/CinemaLikeRepository.java b/src/main/java/com/ceos23/cgv/domain/cinema/repository/CinemaLikeRepository.java new file mode 100644 index 00000000..ff29076f --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/repository/CinemaLikeRepository.java @@ -0,0 +1,15 @@ +package com.ceos23.cgv.domain.cinema.repository; + +import com.ceos23.cgv.domain.cinema.entity.CinemaLike; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface CinemaLikeRepository extends JpaRepository { + // 유저 ID와 극장 ID로 기존에 찜했는지 찾는 메서드 + Optional findByUserIdAndCinemaId(Long userId, Long cinemaId); + + // 특정 유저가 찜한 극장 목록만 모아보기 + List findByUserId(Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/repository/CinemaRepository.java b/src/main/java/com/ceos23/cgv/domain/cinema/repository/CinemaRepository.java new file mode 100644 index 00000000..c13e89e7 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/repository/CinemaRepository.java @@ -0,0 +1,7 @@ +package com.ceos23.cgv.domain.cinema.repository; + +import com.ceos23.cgv.domain.cinema.entity.Cinema; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CinemaRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/repository/ReviewRepository.java b/src/main/java/com/ceos23/cgv/domain/cinema/repository/ReviewRepository.java new file mode 100644 index 00000000..dc98b0d5 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/repository/ReviewRepository.java @@ -0,0 +1,10 @@ +package com.ceos23.cgv.domain.cinema.repository; + +import com.ceos23.cgv.domain.cinema.entity.Review; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface ReviewRepository extends JpaRepository { + // 특정 영화(movieId)에 달린 리뷰들만 가져오는 메서드 + List findByMovieId(Long movieId); +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/repository/TheaterRepository.java b/src/main/java/com/ceos23/cgv/domain/cinema/repository/TheaterRepository.java new file mode 100644 index 00000000..cbfa6178 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/repository/TheaterRepository.java @@ -0,0 +1,11 @@ +package com.ceos23.cgv.domain.cinema.repository; + +import com.ceos23.cgv.domain.cinema.entity.Theater; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface TheaterRepository extends JpaRepository { + // 특정 지점(Cinema)에 속한 상영관 목록 조회 + List findByCinemaId(Long cinemaId); +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/service/CinemaLikeService.java b/src/main/java/com/ceos23/cgv/domain/cinema/service/CinemaLikeService.java new file mode 100644 index 00000000..60e59fea --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/service/CinemaLikeService.java @@ -0,0 +1,61 @@ +package com.ceos23.cgv.domain.cinema.service; + +import com.ceos23.cgv.domain.cinema.entity.Cinema; +import com.ceos23.cgv.domain.cinema.entity.CinemaLike; +import com.ceos23.cgv.domain.cinema.repository.CinemaLikeRepository; +import com.ceos23.cgv.domain.cinema.repository.CinemaRepository; +import com.ceos23.cgv.domain.user.entity.User; +import com.ceos23.cgv.domain.user.repository.UserRepository; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CinemaLikeService { + + private final CinemaLikeRepository cinemaLikeRepository; + private final UserRepository userRepository; + private final CinemaRepository cinemaRepository; + + /** + * [POST] 극장 찜하기 토글 (없으면 생성, 있으면 삭제) + */ + @Transactional + public String toggleCinemaLike(Long userId, Long cinemaId) { + User user = findUser(userId); + Cinema cinema = findCinema(cinemaId); + Optional existingLike = cinemaLikeRepository.findByUserIdAndCinemaId(userId, cinemaId); + + if (existingLike.isPresent()) { + cinemaLikeRepository.delete(existingLike.get()); + return "자주 가는 극장이 취소되었습니다."; + } else { + cinemaLikeRepository.save(CinemaLike.create(user, cinema)); + return "자주 가는 극장으로 등록되었습니다."; + } + } + + private User findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + } + + private Cinema findCinema(Long cinemaId) { + return cinemaRepository.findById(cinemaId) + .orElseThrow(() -> new CustomException(ErrorCode.CINEMA_NOT_FOUND)); + } + + /** + * [GET] 특정 유저가 찜한(자주 가는) 극장 목록 조회 + */ + public List getLikedCinemasByUser(Long userId) { + return cinemaLikeRepository.findByUserId(userId); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/service/CinemaService.java b/src/main/java/com/ceos23/cgv/domain/cinema/service/CinemaService.java new file mode 100644 index 00000000..1c58925c --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/service/CinemaService.java @@ -0,0 +1,84 @@ +package com.ceos23.cgv.domain.cinema.service; + +import com.ceos23.cgv.domain.cinema.dto.TheaterCreateRequest; +import com.ceos23.cgv.domain.cinema.entity.Cinema; +import com.ceos23.cgv.domain.cinema.entity.Theater; +import com.ceos23.cgv.domain.cinema.enums.Region; +import com.ceos23.cgv.domain.cinema.repository.CinemaRepository; +import com.ceos23.cgv.domain.cinema.repository.TheaterRepository; +import com.ceos23.cgv.global.cache.CacheNames; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CinemaService { + + private final CinemaRepository cinemaRepository; + private final TheaterRepository theaterRepository; + + /** + * 전체 영화관(지점) 목록 조회 + */ + @Cacheable(cacheNames = CacheNames.CINEMAS, key = "'all'") + public List getAllCinema() { + return cinemaRepository.findAll(); + } + + /** + * 단일 영화관(지점) 상세 조회 + */ + @Cacheable(cacheNames = CacheNames.CINEMA_DETAILS, key = "#cinemaId") + public Cinema getCinemaDetails(Long cinemaId) { + return cinemaRepository.findById(cinemaId) + .orElseThrow(() -> new CustomException(ErrorCode.CINEMA_NOT_FOUND)); + } + + /** + * 특정 영화관의 전체 상영관(Theater) 목록 조회 + */ + public List getTheatersByCinemaId(Long cinemaId) { + // 먼저 영화관이 존재하는지 검증 + if (!cinemaRepository.existsById(cinemaId)) { + throw new CustomException(ErrorCode.CINEMA_NOT_FOUND); + } + return theaterRepository.findByCinemaId(cinemaId); + } + + /** + * [POST] 새로운 영화관(지점) 생성 + */ + @Transactional + @CacheEvict(cacheNames = {CacheNames.CINEMAS, CacheNames.CINEMA_DETAILS}, allEntries = true) + public Cinema createCinema(String name, Region region) { + Cinema cinema = Cinema.create(name, region); + return cinemaRepository.save(cinema); + } + + /** + * [POST] 특정 영화관 내에 새로운 상영관 생성 + */ + @Transactional + public Theater createTheater(Long cinemaId, TheaterCreateRequest request) { + Cinema cinema = cinemaRepository.findById(cinemaId) + .orElseThrow(() -> new CustomException(ErrorCode.CINEMA_NOT_FOUND)); + + Theater theater = Theater.create( + cinema, + request.name(), + request.type(), + request.maxRow(), + request.maxCol() + ); + + return theaterRepository.save(theater); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/cinema/service/ReviewService.java b/src/main/java/com/ceos23/cgv/domain/cinema/service/ReviewService.java new file mode 100644 index 00000000..6c5a7fed --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/cinema/service/ReviewService.java @@ -0,0 +1,55 @@ +package com.ceos23.cgv.domain.cinema.service; + +import com.ceos23.cgv.domain.cinema.entity.Review; +import com.ceos23.cgv.domain.cinema.repository.ReviewRepository; +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.domain.movie.repository.MovieRepository; +import com.ceos23.cgv.domain.user.entity.User; +import com.ceos23.cgv.domain.user.repository.UserRepository; +import com.ceos23.cgv.domain.cinema.dto.ReviewCreateRequest; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReviewService { + + private final ReviewRepository reviewRepository; + private final UserRepository userRepository; + private final MovieRepository movieRepository; + + /** + * [POST] 관람평 작성 + */ + @Transactional + public Review createReview(ReviewCreateRequest request) { + User user = findUser(request.userId()); + Movie movie = findMovie(request.movieId()); + Review review = Review.create(user, movie, request.type(), request.content()); + + return reviewRepository.save(review); + } + + private User findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + } + + private Movie findMovie(Long movieId) { + return movieRepository.findById(movieId) + .orElseThrow(() -> new CustomException(ErrorCode.MOVIE_NOT_FOUND)); + } + + /** + * [GET] 특정 영화의 관람평 목록 조회 + */ + public List getReviewsByMovieId(Long movieId) { + return reviewRepository.findByMovieId(movieId); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/concession/controller/ConcessionController.java b/src/main/java/com/ceos23/cgv/domain/concession/controller/ConcessionController.java new file mode 100644 index 00000000..1e99fec0 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/controller/ConcessionController.java @@ -0,0 +1,52 @@ +package com.ceos23.cgv.domain.concession.controller; + +import com.ceos23.cgv.domain.concession.dto.FoodOrderRequest; +import com.ceos23.cgv.domain.concession.dto.FoodOrderResponse; +import com.ceos23.cgv.domain.concession.dto.ProductResponse; +import com.ceos23.cgv.domain.concession.entity.FoodOrder; +import com.ceos23.cgv.domain.concession.service.ConcessionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/concessions") +@RequiredArgsConstructor +@Tag(name = "Concession API", description = "매점 상품 조회 및 픽업 주문 API") +public class ConcessionController { + + private final ConcessionService concessionService; + + @GetMapping("/products") + @Operation(summary = "매점 상품 전체 조회", description = "팝콘, 음료 등 매점에서 판매하는 전체 상품 목록을 가져옵니다.") + public ResponseEntity> getAllProducts() { + List responses = concessionService.getAllProducts().stream() + .map(ProductResponse::from) + .collect(Collectors.toList()); + return ResponseEntity.ok(responses); + } + + @PostMapping("/orders") + @Operation(summary = "매점 패스트오더 주문", description = "유저, 픽업 영화관, 그리고 장바구니에 담긴 상품 목록들을 받아 총액을 계산하고 주문을 생성합니다.") + public ResponseEntity createOrder(@RequestBody FoodOrderRequest request) { + FoodOrder order = concessionService.createOrder(request); + return ResponseEntity.status(HttpStatus.CREATED) + .body(FoodOrderResponse.from(order)); + } + + @GetMapping("/orders") + @Operation(summary = "유저별 매점 주문 내역 조회", description = "특정 유저(userId)가 주문한 매점 결제 내역 목록을 조회합니다.") + public ResponseEntity> getOrdersByUserId(@RequestParam Long userId) { + // 임시로 @RequestParam을 통해 userId를 쿼리 파라미터로 받습니다. (예: /api/concessions/orders?userId=1) + List responses = concessionService.getOrdersByUserId(userId).stream() + .map(FoodOrderResponse::from) + .collect(Collectors.toList()); + return ResponseEntity.ok(responses); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/concession/controller/InventoryController.java b/src/main/java/com/ceos23/cgv/domain/concession/controller/InventoryController.java new file mode 100644 index 00000000..723f8f69 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/controller/InventoryController.java @@ -0,0 +1,30 @@ +package com.ceos23.cgv.domain.concession.controller; + +import com.ceos23.cgv.domain.concession.dto.InventoryResponse; +import com.ceos23.cgv.domain.concession.service.InventoryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/inventories") +@RequiredArgsConstructor +@Tag(name = "Inventory API", description = "지점별 매점 상품 재고 관리 API") +public class InventoryController { + + private final InventoryService inventoryService; + + @GetMapping("/cinema/{cinemaId}") + @Operation(summary = "지점별 재고 조회", description = "극장 ID를 통해 해당 지점의 모든 상품 재고 목록을 조회합니다.") + public ResponseEntity> getInventoriesByCinema(@PathVariable Long cinemaId) { + List responses = inventoryService.getInventoriesByCinemaId(cinemaId).stream() + .map(InventoryResponse::from) + .collect(Collectors.toList()); + return ResponseEntity.ok(responses); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/concession/controller/admin/AdminConcessionController.java b/src/main/java/com/ceos23/cgv/domain/concession/controller/admin/AdminConcessionController.java new file mode 100644 index 00000000..ca132039 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/controller/admin/AdminConcessionController.java @@ -0,0 +1,40 @@ +package com.ceos23.cgv.domain.concession.controller.admin; + +import com.ceos23.cgv.domain.concession.dto.ProductCreateRequest; +import com.ceos23.cgv.domain.concession.dto.ProductResponse; +import com.ceos23.cgv.domain.concession.entity.Product; +import com.ceos23.cgv.domain.concession.service.ConcessionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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/admin/concessions") +@RequiredArgsConstructor +@Tag(name = "Admin Concession API", description = "관리자 전용 매점 상품 관리 API") +public class AdminConcessionController { + + private final ConcessionService concessionService; + + @PostMapping("/products") + @Operation(summary = "매점 상품 등록", description = "새로운 매점 상품(팝콘, 콤보 등)을 DB에 등록합니다.") + public ResponseEntity createProduct(@RequestBody ProductCreateRequest request) { + Product createdProduct = concessionService.createProduct( + request.name(), + request.price(), + request.description(), + request.origin(), + request.ingredient(), + request.pickupPossible(), + request.category() + ); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ProductResponse.from(createdProduct)); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/concession/controller/admin/AdminInventoryController.java b/src/main/java/com/ceos23/cgv/domain/concession/controller/admin/AdminInventoryController.java new file mode 100644 index 00000000..b13773b4 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/controller/admin/AdminInventoryController.java @@ -0,0 +1,30 @@ +package com.ceos23.cgv.domain.concession.controller.admin; + +import com.ceos23.cgv.domain.concession.dto.InventoryResponse; +import com.ceos23.cgv.domain.concession.dto.InventoryUpdateRequest; +import com.ceos23.cgv.domain.concession.entity.Inventory; +import com.ceos23.cgv.domain.concession.service.InventoryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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/admin/inventories") +@RequiredArgsConstructor +@Tag(name = "Admin Inventory API", description = "관리자 전용 지점별 매점 상품 재고 관리 API") +public class AdminInventoryController { + + private final InventoryService inventoryService; + + @PostMapping("/update") + @Operation(summary = "재고 업데이트", description = "특정 지점의 상품 재고를 추가하거나 차감합니다. 재고는 최소 1개 이상이어야 합니다.") + public ResponseEntity updateInventory(@RequestBody InventoryUpdateRequest request) { + Inventory inventory = inventoryService.updateInventory(request); + return ResponseEntity.ok(InventoryResponse.from(inventory)); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/concession/dto/FoodOrderRequest.java b/src/main/java/com/ceos23/cgv/domain/concession/dto/FoodOrderRequest.java new file mode 100644 index 00000000..45e1f4cd --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/dto/FoodOrderRequest.java @@ -0,0 +1,14 @@ +package com.ceos23.cgv.domain.concession.dto; + +import java.util.List; + +public record FoodOrderRequest( + Long userId, + Long cinemaId, + List orderItems +) { + public record OrderItemRequest( + Long productId, + int quantity + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/concession/dto/FoodOrderResponse.java b/src/main/java/com/ceos23/cgv/domain/concession/dto/FoodOrderResponse.java new file mode 100644 index 00000000..d79ff08d --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/dto/FoodOrderResponse.java @@ -0,0 +1,19 @@ +package com.ceos23.cgv.domain.concession.dto; + +import com.ceos23.cgv.domain.concession.entity.FoodOrder; + +public record FoodOrderResponse( + Long orderId, + String userName, + String cinemaName, + int totalPrice +) { + public static FoodOrderResponse from(FoodOrder foodOrder) { + return new FoodOrderResponse( + foodOrder.getId(), + foodOrder.getUser().getNickname(), + foodOrder.getCinema().getName(), + foodOrder.getTotalPrice() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/concession/dto/InventoryResponse.java b/src/main/java/com/ceos23/cgv/domain/concession/dto/InventoryResponse.java new file mode 100644 index 00000000..7dde7f94 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/dto/InventoryResponse.java @@ -0,0 +1,19 @@ +package com.ceos23.cgv.domain.concession.dto; + +import com.ceos23.cgv.domain.concession.entity.Inventory; + +public record InventoryResponse( + Long inventoryId, + String cinemaName, + String productName, + int stockQuantity +) { + public static InventoryResponse from(Inventory inventory) { + return new InventoryResponse( + inventory.getId(), + inventory.getCinema().getName(), + inventory.getProduct().getName(), + inventory.getStockQuantity() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/concession/dto/InventoryUpdateRequest.java b/src/main/java/com/ceos23/cgv/domain/concession/dto/InventoryUpdateRequest.java new file mode 100644 index 00000000..a03647a2 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/dto/InventoryUpdateRequest.java @@ -0,0 +1,8 @@ +package com.ceos23.cgv.domain.concession.dto; + +public record InventoryUpdateRequest( + Long cinemaId, + Long productId, + int quantity +) { +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/concession/dto/ProductCreateRequest.java b/src/main/java/com/ceos23/cgv/domain/concession/dto/ProductCreateRequest.java new file mode 100644 index 00000000..33fb3192 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/dto/ProductCreateRequest.java @@ -0,0 +1,14 @@ +package com.ceos23.cgv.domain.concession.dto; + +import com.ceos23.cgv.domain.concession.enums.ProductCategory; + +public record ProductCreateRequest( + String name, + int price, + String description, + String origin, + String ingredient, + Boolean pickupPossible, + ProductCategory category +) { +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/concession/dto/ProductResponse.java b/src/main/java/com/ceos23/cgv/domain/concession/dto/ProductResponse.java new file mode 100644 index 00000000..7303858e --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/dto/ProductResponse.java @@ -0,0 +1,22 @@ +package com.ceos23.cgv.domain.concession.dto; + +import com.ceos23.cgv.domain.concession.entity.Product; +import com.ceos23.cgv.domain.concession.enums.ProductCategory; + +public record ProductResponse( + Long productId, + String name, + int price, + String description, + ProductCategory category +) { + public static ProductResponse from(Product product) { + return new ProductResponse( + product.getId(), + product.getName(), + product.getPrice(), + product.getDescription(), + product.getCategory() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/concession/entity/FoodOrder.java b/src/main/java/com/ceos23/cgv/domain/concession/entity/FoodOrder.java new file mode 100644 index 00000000..27ace8b7 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/entity/FoodOrder.java @@ -0,0 +1,84 @@ +package com.ceos23.cgv.domain.concession.entity; + +import com.ceos23.cgv.domain.cinema.entity.Cinema; +import com.ceos23.cgv.domain.concession.enums.FoodOrderStatus; +import com.ceos23.cgv.domain.user.entity.User; +import com.ceos23.cgv.global.entity.BaseTimeEntity; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import jakarta.persistence.*; +import lombok.*; + +import java.util.List; + +@Entity +@Table(name = "food_orders", indexes = { + @Index(name = "idx_food_orders_user_status", columnList = "user_id, status") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class FoodOrder extends BaseTimeEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "order_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cinema_id", nullable = false) + private Cinema cinema; + + @Column(nullable = false) + private int totalPrice; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private FoodOrderStatus status; + + @Column(nullable = false, unique = true) + private String paymentId; + + public static FoodOrder create(User user, Cinema cinema, String paymentId) { + return FoodOrder.builder() + .user(user) + .cinema(cinema) + .totalPrice(0) + .status(FoodOrderStatus.PENDING) + .paymentId(paymentId) + .build(); + } + + // 총 결제 금액을 업데이트하는 메서드 + public void updateTotalPrice(int totalPrice) { + this.totalPrice = totalPrice; + } + + public void calculateTotalPrice(List orderItems) { + this.totalPrice = orderItems.stream() + .mapToInt(OrderItem::calculatePrice) + .sum(); + } + + public void completePayment() { + validatePending(); + this.status = FoodOrderStatus.COMPLETED; + } + + public void cancel() { + if (this.status == FoodOrderStatus.CANCELED) { + return; + } + this.status = FoodOrderStatus.CANCELED; + } + + public void validatePending() { + if (this.status != FoodOrderStatus.PENDING) { + throw new CustomException(ErrorCode.FOOD_ORDER_NOT_PENDING); + } + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/concession/entity/Inventory.java b/src/main/java/com/ceos23/cgv/domain/concession/entity/Inventory.java new file mode 100644 index 00000000..3bd47cce --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/entity/Inventory.java @@ -0,0 +1,78 @@ +package com.ceos23.cgv.domain.concession.entity; + +import com.ceos23.cgv.domain.cinema.entity.Cinema; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import jakarta.persistence.*; +import jakarta.validation.constraints.Min; +import lombok.*; + +@Entity +@Table(name = "inventories", indexes = { + @Index(name = "idx_inventories_cinema_product", columnList = "cinema_id, product_id") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Inventory { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "inventory_id") + private long id; + + @Min(value = 1, message = "재고는 최소 1개 이상입니다.") + @Column(nullable = false) + private int stockQuantity; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cinema_id", nullable = false) + private Cinema cinema; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + public static Inventory create(Cinema cinema, Product product, int stockQuantity) { + validatePositiveStock(stockQuantity); + + return Inventory.builder() + .cinema(cinema) + .product(product) + .stockQuantity(stockQuantity) + .build(); + } + + // 재고 차감 로직 + public void removeStock(int quantity) { + if (this.stockQuantity < quantity) { + // 빼려는 수량보다 남은 재고가 적으면 에러 발생 + throw new CustomException(ErrorCode.INVENTORY_SHORTAGE); + } + this.stockQuantity -= quantity; + } + + public void changeStockBy(int quantity) { + int changedStockQuantity = this.stockQuantity + quantity; + validateSufficientStock(changedStockQuantity); + this.stockQuantity = changedStockQuantity; + } + + // 재고 수정 메서드 + public void updateStock(int stockQuantity) { + validatePositiveStock(stockQuantity); + this.stockQuantity = stockQuantity; + } + + private static void validatePositiveStock(int stockQuantity) { + if (stockQuantity < 1) { + throw new CustomException(ErrorCode.INVALID_STOCK_QUANTITY); + } + } + + private static void validateSufficientStock(int stockQuantity) { + if (stockQuantity < 1) { + throw new CustomException(ErrorCode.INVENTORY_SHORTAGE); + } + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/concession/entity/OrderItem.java b/src/main/java/com/ceos23/cgv/domain/concession/entity/OrderItem.java new file mode 100644 index 00000000..c41f2f66 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/entity/OrderItem.java @@ -0,0 +1,44 @@ +package com.ceos23.cgv.domain.concession.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.Min; +import lombok.*; + +@Entity +@Table(name = "order_items", indexes = { + @Index(name = "idx_order_items_order_id", columnList = "order_id") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class OrderItem { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "order_item_id") + private Long id; + + @Min(value = 1, message = "최소 1개 이상 구매해야합니다.") + @Column(nullable = false) + private int quantity; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + private FoodOrder foodOrder; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + public static OrderItem create(FoodOrder foodOrder, Product product, int quantity) { + return OrderItem.builder() + .foodOrder(foodOrder) + .product(product) + .quantity(quantity) + .build(); + } + + public int calculatePrice() { + return product.getPrice() * quantity; + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/concession/entity/Product.java b/src/main/java/com/ceos23/cgv/domain/concession/entity/Product.java new file mode 100644 index 00000000..0c55cb57 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/entity/Product.java @@ -0,0 +1,53 @@ +package com.ceos23.cgv.domain.concession.entity; + +import com.ceos23.cgv.domain.concession.enums.ProductCategory; +import com.ceos23.cgv.global.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "products") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Product extends BaseTimeEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "product_id") + private Long id; + + @Column(nullable = false, length = 100) + private String name; + + @Column(nullable = false) + private int price; + + @Column(columnDefinition = "TEXT") + private String description; + + private String origin; + + @Column(columnDefinition = "TEXT") + private String ingredient; + + @Column(nullable = false) + private Boolean pickupPossible; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ProductCategory category; + + public static Product create(String name, int price, String description, String origin, + String ingredient, Boolean pickupPossible, ProductCategory category) { + return Product.builder() + .name(name) + .price(price) + .description(description) + .origin(origin) + .ingredient(ingredient) + .pickupPossible(pickupPossible) + .category(category) + .build(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/concession/enums/FoodOrderStatus.java b/src/main/java/com/ceos23/cgv/domain/concession/enums/FoodOrderStatus.java new file mode 100644 index 00000000..6aa0971f --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/enums/FoodOrderStatus.java @@ -0,0 +1,14 @@ +package com.ceos23.cgv.domain.concession.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum FoodOrderStatus { + PENDING("결제 대기"), + COMPLETED("결제 완료"), + CANCELED("주문 취소"); + + private final String description; +} diff --git a/src/main/java/com/ceos23/cgv/domain/concession/enums/ProductCategory.java b/src/main/java/com/ceos23/cgv/domain/concession/enums/ProductCategory.java new file mode 100644 index 00000000..e5be0d49 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/enums/ProductCategory.java @@ -0,0 +1,15 @@ +package com.ceos23.cgv.domain.concession.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ProductCategory { + POPCORN("팝콘"), + DRINK("음료"), + SNACK("스낵"), + COMBO("콤보"); + + private final String description; +} diff --git a/src/main/java/com/ceos23/cgv/domain/concession/repository/FoodOrderRepository.java b/src/main/java/com/ceos23/cgv/domain/concession/repository/FoodOrderRepository.java new file mode 100644 index 00000000..8780288c --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/repository/FoodOrderRepository.java @@ -0,0 +1,22 @@ +package com.ceos23.cgv.domain.concession.repository; + +import com.ceos23.cgv.domain.concession.entity.FoodOrder; +import com.ceos23.cgv.domain.concession.enums.FoodOrderStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface FoodOrderRepository extends JpaRepository { + // Fetch Join을 적용하여 User와 Cinema를 한 번의 쿼리로 가져옵니다 + @Query("SELECT f FROM FoodOrder f " + + "JOIN FETCH f.user " + + "JOIN FETCH f.cinema " + + "WHERE f.user.id = :userId AND f.status = :status") + List findByUserIdAndStatusWithFetchJoin(@Param("userId") Long userId, + @Param("status") FoodOrderStatus status); + + Optional findByPaymentId(String paymentId); +} diff --git a/src/main/java/com/ceos23/cgv/domain/concession/repository/InventoryRepository.java b/src/main/java/com/ceos23/cgv/domain/concession/repository/InventoryRepository.java new file mode 100644 index 00000000..46a4b55d --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/repository/InventoryRepository.java @@ -0,0 +1,25 @@ +package com.ceos23.cgv.domain.concession.repository; + +import com.ceos23.cgv.domain.concession.entity.Inventory; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface InventoryRepository extends JpaRepository { + // 1. 특정 극장(지점)의 모든 매점 상품 재고 목록 조회 + List findByCinemaId(Long cinemaId); + + // 2. 극장과 상품 조합으로 특정 재고 데이터 단건 조회 (업데이트 시 필요) + Optional findByCinemaIdAndProductId(Long cinemaId, Long productId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT i FROM Inventory i " + + "WHERE i.cinema.id = :cinemaId AND i.product.id = :productId") + Optional findByCinemaIdAndProductIdForUpdate(@Param("cinemaId") Long cinemaId, + @Param("productId") Long productId); +} diff --git a/src/main/java/com/ceos23/cgv/domain/concession/repository/OrderItemRepository.java b/src/main/java/com/ceos23/cgv/domain/concession/repository/OrderItemRepository.java new file mode 100644 index 00000000..ead08bec --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/repository/OrderItemRepository.java @@ -0,0 +1,11 @@ +package com.ceos23.cgv.domain.concession.repository; + +import com.ceos23.cgv.domain.concession.entity.OrderItem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OrderItemRepository extends JpaRepository { + + List findByFoodOrderId(Long foodOrderId); +} diff --git a/src/main/java/com/ceos23/cgv/domain/concession/repository/ProductRepository.java b/src/main/java/com/ceos23/cgv/domain/concession/repository/ProductRepository.java new file mode 100644 index 00000000..1bfe4ba2 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/repository/ProductRepository.java @@ -0,0 +1,7 @@ +package com.ceos23.cgv.domain.concession.repository; + +import com.ceos23.cgv.domain.concession.entity.Product; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/concession/service/ConcessionService.java b/src/main/java/com/ceos23/cgv/domain/concession/service/ConcessionService.java new file mode 100644 index 00000000..d75ffb0a --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/service/ConcessionService.java @@ -0,0 +1,222 @@ +package com.ceos23.cgv.domain.concession.service; + +import com.ceos23.cgv.domain.cinema.entity.Cinema; +import com.ceos23.cgv.domain.cinema.repository.CinemaRepository; +import com.ceos23.cgv.domain.concession.dto.FoodOrderRequest; +import com.ceos23.cgv.domain.concession.entity.FoodOrder; +import com.ceos23.cgv.domain.concession.entity.Inventory; +import com.ceos23.cgv.domain.concession.entity.OrderItem; +import com.ceos23.cgv.domain.concession.entity.Product; +import com.ceos23.cgv.domain.concession.enums.FoodOrderStatus; +import com.ceos23.cgv.domain.concession.enums.ProductCategory; +import com.ceos23.cgv.domain.concession.repository.FoodOrderRepository; +import com.ceos23.cgv.domain.concession.repository.InventoryRepository; +import com.ceos23.cgv.domain.concession.repository.OrderItemRepository; +import com.ceos23.cgv.domain.concession.repository.ProductRepository; +import com.ceos23.cgv.domain.payment.service.PaymentService; +import com.ceos23.cgv.domain.user.entity.User; +import com.ceos23.cgv.domain.user.repository.UserRepository; +import com.ceos23.cgv.global.cache.CacheNames; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@Slf4j +public class ConcessionService { + + private final ProductRepository productRepository; + private final FoodOrderRepository foodOrderRepository; + private final OrderItemRepository orderItemRepository; + private final UserRepository userRepository; + private final CinemaRepository cinemaRepository; + private final InventoryRepository inventoryRepository; + private final PaymentService paymentService; + private final TransactionTemplate transactionTemplate; + + public ConcessionService(ProductRepository productRepository, + FoodOrderRepository foodOrderRepository, + OrderItemRepository orderItemRepository, + UserRepository userRepository, + CinemaRepository cinemaRepository, + InventoryRepository inventoryRepository, + PaymentService paymentService, + PlatformTransactionManager transactionManager) { + this.productRepository = productRepository; + this.foodOrderRepository = foodOrderRepository; + this.orderItemRepository = orderItemRepository; + this.userRepository = userRepository; + this.cinemaRepository = cinemaRepository; + this.inventoryRepository = inventoryRepository; + this.paymentService = paymentService; + this.transactionTemplate = new TransactionTemplate(transactionManager); + } + + /** + * [GET] 매점의 모든 상품 목록 조회 + */ + @Cacheable(cacheNames = CacheNames.CONCESSION_PRODUCTS, key = "'all'") + public List getAllProducts() { + return productRepository.findAll(); + } + + /** + * [POST] 매점 상품 주문하기 (복합 로직) + */ + public FoodOrder createOrder(FoodOrderRequest request) { + FoodOrder pendingOrder = transactionTemplate.execute(status -> + createPendingOrder(request) + ); + + try { + paymentService.requestInstantPayment(pendingOrder); + } catch (CustomException e) { + cancelPendingOrder(pendingOrder.getPaymentId()); + throw e; + } catch (RuntimeException e) { + cancelPendingOrder(pendingOrder.getPaymentId()); + throw new CustomException(ErrorCode.PAYMENT_FAILED); + } + + try { + return transactionTemplate.execute(status -> completePaidOrder(pendingOrder.getPaymentId())); + } catch (RuntimeException e) { + compensatePaidOrder(pendingOrder.getPaymentId()); + throw e; + } + } + + private FoodOrder createPendingOrder(FoodOrderRequest request) { + User user = findUser(request.userId()); + Cinema cinema = findCinema(request.cinemaId()); + FoodOrder foodOrder = FoodOrder.create(user, cinema, paymentService.createFoodOrderPaymentId()); + foodOrderRepository.save(foodOrder); + + Map productMap = loadProductMap(request); + List orderItems = createOrderItems(request, foodOrder, productMap); + orderItemRepository.saveAll(orderItems); + + foodOrder.calculateTotalPrice(orderItems); + + return foodOrder; + } + + private FoodOrder completePaidOrder(String paymentId) { + FoodOrder foodOrder = findOrderByPaymentId(paymentId); + foodOrder.validatePending(); + + List orderItems = orderItemRepository.findByFoodOrderId(foodOrder.getId()); + decreaseInventoryStocks(foodOrder.getCinema().getId(), orderItems); + foodOrder.completePayment(); + + return foodOrder; + } + + private void cancelPendingOrder(String paymentId) { + transactionTemplate.executeWithoutResult(status -> { + FoodOrder foodOrder = findOrderByPaymentId(paymentId); + foodOrder.cancel(); + }); + } + + private void compensatePaidOrder(String paymentId) { + try { + paymentService.cancelPayment(paymentId); + } catch (RuntimeException e) { + log.error("매점 외부 결제 취소 보상 처리에 실패했습니다. paymentId={}", paymentId, e); + } + + cancelPendingOrder(paymentId); + } + + private User findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + } + + private Cinema findCinema(Long cinemaId) { + return cinemaRepository.findById(cinemaId) + .orElseThrow(() -> new CustomException(ErrorCode.CINEMA_NOT_FOUND)); + } + + private FoodOrder findOrderByPaymentId(String paymentId) { + return foodOrderRepository.findByPaymentId(paymentId) + .orElseThrow(() -> new CustomException(ErrorCode.FOOD_ORDER_NOT_FOUND)); + } + + private Map loadProductMap(FoodOrderRequest request) { + List productIds = request.orderItems().stream() + .map(FoodOrderRequest.OrderItemRequest::productId) + .toList(); + + return productRepository.findAllById(productIds).stream() + .collect(Collectors.toMap(Product::getId, product -> product)); + } + + private List createOrderItems(FoodOrderRequest request, FoodOrder foodOrder, + Map productMap) { + return request.orderItems().stream() + .map(itemReq -> createOrderItem(foodOrder, productMap, itemReq)) + .toList(); + } + + private OrderItem createOrderItem(FoodOrder foodOrder, Map productMap, + FoodOrderRequest.OrderItemRequest itemReq) { + Product product = getRequiredProduct(productMap, itemReq.productId()); + + return OrderItem.create(foodOrder, product, itemReq.quantity()); + } + + private Product getRequiredProduct(Map productMap, Long productId) { + Product product = productMap.get(productId); + if (product == null) { + throw new CustomException(ErrorCode.PRODUCT_NOT_FOUND); + } + + return product; + } + + private void decreaseInventoryStocks(Long cinemaId, List orderItems) { + orderItems.stream() + .sorted(Comparator.comparing(orderItem -> orderItem.getProduct().getId())) + .forEach(orderItem -> + decreaseInventoryStock(cinemaId, orderItem.getProduct().getId(), orderItem.getQuantity()) + ); + } + + private void decreaseInventoryStock(Long cinemaId, Long productId, int quantity) { + Inventory inventory = inventoryRepository.findByCinemaIdAndProductIdForUpdate(cinemaId, productId) + .orElseThrow(() -> new CustomException(ErrorCode.INVENTORY_SHORTAGE)); + + inventory.removeStock(quantity); + } + + /** + * [POST] 새로운 매점 상품 등록 (관리자용) + */ + @CacheEvict(cacheNames = CacheNames.CONCESSION_PRODUCTS, allEntries = true) + public Product createProduct(String name, int price, String description, + String origin, String ingredient, + Boolean pickupPossible, ProductCategory category) { + Product product = Product.create(name, price, description, origin, ingredient, pickupPossible, category); + return transactionTemplate.execute(status -> productRepository.save(product)); + } + + /** + * [GET] 특정 유저의 매점 주문 내역 조회 + */ + public List getOrdersByUserId(Long userId) { + // N+1 문제를 방지하는 페치 조인 메서드 호출 + return foodOrderRepository.findByUserIdAndStatusWithFetchJoin(userId, FoodOrderStatus.COMPLETED); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/concession/service/InventoryService.java b/src/main/java/com/ceos23/cgv/domain/concession/service/InventoryService.java new file mode 100644 index 00000000..58b06009 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/concession/service/InventoryService.java @@ -0,0 +1,59 @@ +package com.ceos23.cgv.domain.concession.service; + +import com.ceos23.cgv.domain.cinema.entity.Cinema; +import com.ceos23.cgv.domain.cinema.repository.CinemaRepository; +import com.ceos23.cgv.domain.concession.dto.InventoryUpdateRequest; +import com.ceos23.cgv.domain.concession.entity.Inventory; +import com.ceos23.cgv.domain.concession.entity.Product; +import com.ceos23.cgv.domain.concession.repository.InventoryRepository; +import com.ceos23.cgv.domain.concession.repository.ProductRepository; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class InventoryService { + + private final InventoryRepository inventoryRepository; + private final CinemaRepository cinemaRepository; + private final ProductRepository productRepository; + + @Transactional + public Inventory updateInventory(InventoryUpdateRequest request) { + Cinema cinema = findCinema(request.cinemaId()); + Product product = findProduct(request.productId()); + + return inventoryRepository.findByCinemaIdAndProductId(request.cinemaId(), request.productId()) + .map(inventory -> updateExistingInventory(inventory, request.quantity())) + .orElseGet(() -> createInventory(cinema, product, request.quantity())); + } + + private Cinema findCinema(Long cinemaId) { + return cinemaRepository.findById(cinemaId) + .orElseThrow(() -> new CustomException(ErrorCode.CINEMA_NOT_FOUND)); + } + + private Product findProduct(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new CustomException(ErrorCode.PRODUCT_NOT_FOUND)); + } + + private Inventory updateExistingInventory(Inventory inventory, int quantity) { + inventory.changeStockBy(quantity); + return inventory; + } + + private Inventory createInventory(Cinema cinema, Product product, int quantity) { + return inventoryRepository.save(Inventory.create(cinema, product, quantity)); + } + + public List getInventoriesByCinemaId(Long cinemaId) { + return inventoryRepository.findByCinemaId(cinemaId); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/event/controller/EventController.java b/src/main/java/com/ceos23/cgv/domain/event/controller/EventController.java new file mode 100644 index 00000000..424e65d2 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/event/controller/EventController.java @@ -0,0 +1,39 @@ +package com.ceos23.cgv.domain.event.controller; + +import com.ceos23.cgv.domain.event.dto.EventResponse; +import com.ceos23.cgv.domain.event.service.EventService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/events") +@RequiredArgsConstructor +@Tag(name = "Event API", description = "영화 관련 이벤트(기획전, 무대인사 등) 등록 및 조회 API") +public class EventController { + + private final EventService eventService; + + @GetMapping + @Operation(summary = "전체 이벤트 조회", description = "현재 등록된 모든 이벤트 목록을 가져옵니다.") + public ResponseEntity> getAllEvents() { + List responses = eventService.getAllEvents().stream() + .map(EventResponse::from) + .collect(Collectors.toList()); + return ResponseEntity.ok(responses); + } + + @GetMapping("/movie/{movieId}") + @Operation(summary = "특정 영화의 이벤트 조회", description = "영화 ID를 통해 해당 영화와 관련된 이벤트 목록만 가져옵니다.") + public ResponseEntity> getEventsByMovie(@PathVariable Long movieId) { + List responses = eventService.getEventsByMovieId(movieId).stream() + .map(EventResponse::from) + .collect(Collectors.toList()); + return ResponseEntity.ok(responses); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/event/controller/admin/AdminEventController.java b/src/main/java/com/ceos23/cgv/domain/event/controller/admin/AdminEventController.java new file mode 100644 index 00000000..5a9ed298 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/event/controller/admin/AdminEventController.java @@ -0,0 +1,39 @@ +package com.ceos23.cgv.domain.event.controller.admin; + +import com.ceos23.cgv.domain.event.dto.EventCreateRequest; +import com.ceos23.cgv.domain.event.dto.EventResponse; +import com.ceos23.cgv.domain.event.entity.Event; +import com.ceos23.cgv.domain.event.service.EventService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/admin/events") +@RequiredArgsConstructor +@Tag(name = "Admin Event API", description = "관리자 전용 이벤트 관리 API") +public class AdminEventController { + + private final EventService eventService; + + @PostMapping + @Operation(summary = "이벤트 생성", description = "새로운 이벤트를 등록합니다.") + public ResponseEntity createEvent(@RequestBody EventCreateRequest request) { + Event event = eventService.createEvent(request); + return ResponseEntity.status(HttpStatus.CREATED).body(EventResponse.from(event)); + } + + @PostMapping("/{eventId}/movies/{movieId}") + @Operation(summary = "이벤트-영화 연결", description = "생성된 이벤트를 특정 영화와 연결(매핑)합니다.") + public ResponseEntity linkEventToMovie(@PathVariable Long eventId, @PathVariable Long movieId) { + eventService.linkEventToMovie(eventId, movieId); + return ResponseEntity.status(HttpStatus.CREATED).body("이벤트와 영화가 성공적으로 연결되었습니다."); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/event/dto/EventCreateRequest.java b/src/main/java/com/ceos23/cgv/domain/event/dto/EventCreateRequest.java new file mode 100644 index 00000000..dcd6b2dd --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/event/dto/EventCreateRequest.java @@ -0,0 +1,11 @@ +package com.ceos23.cgv.domain.event.dto; + +import java.time.LocalDateTime; + +public record EventCreateRequest( + String title, + String content, + LocalDateTime startDate, + LocalDateTime endDate +) { +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/event/dto/EventResponse.java b/src/main/java/com/ceos23/cgv/domain/event/dto/EventResponse.java new file mode 100644 index 00000000..84e4464c --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/event/dto/EventResponse.java @@ -0,0 +1,22 @@ +package com.ceos23.cgv.domain.event.dto; + +import com.ceos23.cgv.domain.event.entity.Event; +import java.time.LocalDateTime; + +public record EventResponse( + Long eventId, + String title, + String content, + LocalDateTime startDate, + LocalDateTime endDate +) { + public static EventResponse from(Event event) { + return new EventResponse( + event.getId(), + event.getTitle(), + event.getContent(), + event.getStartDate(), + event.getEndDate() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/event/entity/Event.java b/src/main/java/com/ceos23/cgv/domain/event/entity/Event.java new file mode 100644 index 00000000..ef5ea1f8 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/event/entity/Event.java @@ -0,0 +1,40 @@ +package com.ceos23.cgv.domain.event.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "events") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Event { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "event_id") + private Long id; + + @Column(nullable = false, length = 100) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(nullable = false) + private LocalDateTime startDate; + + @Column(nullable = false) + private LocalDateTime endDate; + + public static Event create(String title, String content, LocalDateTime startDate, LocalDateTime endDate) { + return Event.builder() + .title(title) + .content(content) + .startDate(startDate) + .endDate(endDate) + .build(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/event/entity/MovieEvent.java b/src/main/java/com/ceos23/cgv/domain/event/entity/MovieEvent.java new file mode 100644 index 00000000..ce64a1ce --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/event/entity/MovieEvent.java @@ -0,0 +1,35 @@ +package com.ceos23.cgv.domain.event.entity; + +import com.ceos23.cgv.domain.movie.entity.Movie; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "movie_events", indexes = { + @Index(name = "idx_movie_events_movie_id", columnList = "movie_id") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class MovieEvent { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "movie_event_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "event_id", nullable = false) + private Event event; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "movie_id", nullable = false) + private Movie movie; + + public static MovieEvent link(Event event, Movie movie) { + return MovieEvent.builder() + .event(event) + .movie(movie) + .build(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/event/repository/EventRepository.java b/src/main/java/com/ceos23/cgv/domain/event/repository/EventRepository.java new file mode 100644 index 00000000..56853b87 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/event/repository/EventRepository.java @@ -0,0 +1,7 @@ +package com.ceos23.cgv.domain.event.repository; + +import com.ceos23.cgv.domain.event.entity.Event; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EventRepository extends JpaRepository { +} diff --git a/src/main/java/com/ceos23/cgv/domain/event/repository/MovieEventRepository.java b/src/main/java/com/ceos23/cgv/domain/event/repository/MovieEventRepository.java new file mode 100644 index 00000000..9c9b1006 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/event/repository/MovieEventRepository.java @@ -0,0 +1,11 @@ +package com.ceos23.cgv.domain.event.repository; + +import com.ceos23.cgv.domain.event.entity.MovieEvent; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MovieEventRepository extends JpaRepository { + // 특정 영화와 연관된 이벤트 목록 조회 + List findByMovieId(Long movieId); +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/event/service/EventService.java b/src/main/java/com/ceos23/cgv/domain/event/service/EventService.java new file mode 100644 index 00000000..f4c14693 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/event/service/EventService.java @@ -0,0 +1,88 @@ +package com.ceos23.cgv.domain.event.service; + +import com.ceos23.cgv.domain.event.dto.EventCreateRequest; +import com.ceos23.cgv.domain.event.entity.Event; +import com.ceos23.cgv.domain.event.entity.MovieEvent; +import com.ceos23.cgv.domain.event.repository.EventRepository; +import com.ceos23.cgv.domain.event.repository.MovieEventRepository; +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.domain.movie.repository.MovieRepository; +import com.ceos23.cgv.global.cache.CacheNames; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class EventService { + + private final EventRepository eventRepository; + private final MovieEventRepository movieEventRepository; + private final MovieRepository movieRepository; + + /** + * [POST] 새로운 이벤트 생성 + */ + @Transactional + @CacheEvict(cacheNames = CacheNames.EVENTS, allEntries = true) + public Event createEvent(EventCreateRequest request) { + Event event = Event.create( + request.title(), + request.content(), + request.startDate(), + request.endDate() + ); + + return eventRepository.save(event); + } + + /** + * [POST] 특정 이벤트를 특정 영화와 연결 + */ + @Transactional + @CacheEvict(cacheNames = CacheNames.EVENTS, allEntries = true) + public MovieEvent linkEventToMovie(Long eventId, Long movieId) { + Event event = findEvent(eventId); + Movie movie = findMovie(movieId); + MovieEvent movieEvent = MovieEvent.link(event, movie); + + return movieEventRepository.save(movieEvent); + } + + private Event findEvent(Long eventId) { + return eventRepository.findById(eventId) + .orElseThrow(() -> new CustomException(ErrorCode.EVENT_NOT_FOUND)); + } + + private Movie findMovie(Long movieId) { + return movieRepository.findById(movieId) + .orElseThrow(() -> new CustomException(ErrorCode.MOVIE_NOT_FOUND)); + } + + /** + * [GET] 전체 진행 중인 이벤트 목록 조회 + */ + @Cacheable(cacheNames = CacheNames.EVENTS, key = "'all'") + public List getAllEvents() { + return eventRepository.findAll(); + } + + /** + * [GET] 특정 영화와 관련된 이벤트만 조회 + */ + @Cacheable(cacheNames = CacheNames.EVENTS, key = "'movie:' + #movieId") + public List getEventsByMovieId(Long movieId) { + // MovieEvent(중간 테이블) 목록을 가져와서 Event 객체만 추출하여 반환 + return movieEventRepository.findByMovieId(movieId).stream() + .map(MovieEvent::getEvent) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/movie/controller/MovieLikeController.java b/src/main/java/com/ceos23/cgv/domain/movie/controller/MovieLikeController.java new file mode 100644 index 00000000..135bba3f --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/controller/MovieLikeController.java @@ -0,0 +1,39 @@ +package com.ceos23.cgv.domain.movie.controller; + +import com.ceos23.cgv.domain.movie.dto.MovieLikeResponse; +import com.ceos23.cgv.domain.movie.service.MovieLikeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/movie-likes") +@RequiredArgsConstructor +@Tag(name = "Movie Like API", description = "영화 찜하기 등록/취소 및 조회 API") +public class MovieLikeController { + + private final MovieLikeService movieLikeService; + + @PostMapping("/{movieId}") + @Operation(summary = "영화 찜 토글", description = "특정 영화에 대해 찜 누르거나 이미 눌려있다면 취소합니다.") + public ResponseEntity toggleLike( + @PathVariable Long movieId, + @RequestParam Long userId) { // 임시로 쿼리 파라미터로 유저 ID 받음 + String resultMessage = movieLikeService.toggleMovieLike(userId, movieId); + return ResponseEntity.ok(resultMessage); + } + + @GetMapping("/user/{userId}") + @Operation(summary = "내가 찜 누른 영화 목록 조회", description = "유저 ID를 통해 해당 유저가 찜한 영화 목록을 가져옵니다.") + public ResponseEntity> getLikedMovies(@PathVariable Long userId) { + List responses = movieLikeService.getLikedMoviesByUser(userId).stream() + .map(MovieLikeResponse::from) + .collect(Collectors.toList()); + return ResponseEntity.ok(responses); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/movie/controller/ScreeningController.java b/src/main/java/com/ceos23/cgv/domain/movie/controller/ScreeningController.java new file mode 100644 index 00000000..9995cd4b --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/controller/ScreeningController.java @@ -0,0 +1,30 @@ +package com.ceos23.cgv.domain.movie.controller; + +import com.ceos23.cgv.domain.movie.dto.ScreeningResponse; +import com.ceos23.cgv.domain.movie.service.ScreeningService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/screenings") +@RequiredArgsConstructor +@Tag(name = "Screening API", description = "상영 일정(시간표) 관리 API") +public class ScreeningController { + + private final ScreeningService screeningService; + + @GetMapping("/movie/{movieId}") + @Operation(summary = "특정 영화의 시간표 조회", description = "영화 ID를 통해 해당 영화의 모든 상영 일정(어느 지점, 몇 시 상영 등)을 조회합니다.") + public ResponseEntity> getScreeningsByMovie(@PathVariable Long movieId) { + List responses = screeningService.getScreeningsByMovieId(movieId).stream() + .map(ScreeningResponse::from) + .collect(Collectors.toList()); + return ResponseEntity.ok(responses); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/movie/controller/admin/AdminMovieController.java b/src/main/java/com/ceos23/cgv/domain/movie/controller/admin/AdminMovieController.java new file mode 100644 index 00000000..12dac22d --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/controller/admin/AdminMovieController.java @@ -0,0 +1,40 @@ +package com.ceos23.cgv.domain.movie.controller.admin; + +import com.ceos23.cgv.domain.movie.dto.MovieCreateRequest; +import com.ceos23.cgv.domain.movie.dto.MovieResponse; +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.domain.movie.service.MovieService; +import com.ceos23.cgv.global.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/admin/v1/movies") +@RequiredArgsConstructor +@Tag(name = "Admin Movie API", description = "관리자 전용 영화 등록/삭제 API") +public class AdminMovieController { + + private final MovieService movieService; + + @PostMapping + @Operation(summary = "새로운 영화 등록") + public ResponseEntity> createMovie(@RequestBody MovieCreateRequest request) { + Movie createdMovie = movieService.createMovie( + request.title(), request.runningTime(), request.releaseDate(), + request.movieRating(), request.genre(), request.prologue() + ); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.created(MovieResponse.from(createdMovie))); + } + + @DeleteMapping("/{movieId}") + @Operation(summary = "특정 영화 삭제") + public ResponseEntity> deleteMovie(@PathVariable Long movieId) { + movieService.deleteMovie(movieId); + return ResponseEntity.ok(ApiResponse.success("영화가 성공적으로 삭제되었습니다.")); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/movie/controller/admin/AdminScreeningController.java b/src/main/java/com/ceos23/cgv/domain/movie/controller/admin/AdminScreeningController.java new file mode 100644 index 00000000..9491d94c --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/controller/admin/AdminScreeningController.java @@ -0,0 +1,32 @@ +package com.ceos23.cgv.domain.movie.controller.admin; + +import com.ceos23.cgv.domain.movie.dto.ScreeningCreateRequest; +import com.ceos23.cgv.domain.movie.dto.ScreeningResponse; +import com.ceos23.cgv.domain.movie.entity.Screening; +import com.ceos23.cgv.domain.movie.service.ScreeningService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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/admin/screenings") +@RequiredArgsConstructor +@Tag(name = "Admin Screening API", description = "관리자 전용 상영 일정 관리 API") +public class AdminScreeningController { + + private final ScreeningService screeningService; + + @PostMapping + @Operation(summary = "상영 일정 등록", description = "특정 영화를 특정 상영관에 배치하여 상영 일정을 생성합니다.") + public ResponseEntity createScreening(@RequestBody ScreeningCreateRequest request) { + Screening screening = screeningService.createScreening(request); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ScreeningResponse.from(screening)); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/movie/controller/user/MovieController.java b/src/main/java/com/ceos23/cgv/domain/movie/controller/user/MovieController.java new file mode 100644 index 00000000..e0a8a773 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/controller/user/MovieController.java @@ -0,0 +1,36 @@ +package com.ceos23.cgv.domain.movie.controller.user; + +import com.ceos23.cgv.domain.movie.dto.MovieResponse; +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.domain.movie.service.MovieService; +import com.ceos23.cgv.global.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/movies") +@RequiredArgsConstructor +@Tag(name = "Movie API", description = "일반 고객용 영화 조회 API") +public class MovieController { + + private final MovieService movieService; + + @GetMapping + @Operation(summary = "전체 영화 조회") + public ResponseEntity>> getAllMovies() { + List movies = movieService.getAllMovies(); + return ResponseEntity.ok(ApiResponse.success(movies, MovieResponse::from)); + } + + @GetMapping("/{movieId}") + @Operation(summary = "특정 영화 단건 조회") + public ResponseEntity> getMovieById(@PathVariable Long movieId) { + Movie movie = movieService.getMovieDetails(movieId); + return ResponseEntity.ok(ApiResponse.success(MovieResponse.from(movie))); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/movie/dto/MovieCreateRequest.java b/src/main/java/com/ceos23/cgv/domain/movie/dto/MovieCreateRequest.java new file mode 100644 index 00000000..c53de6d2 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/dto/MovieCreateRequest.java @@ -0,0 +1,15 @@ +package com.ceos23.cgv.domain.movie.dto; + +import com.ceos23.cgv.domain.movie.enums.Genre; +import com.ceos23.cgv.domain.movie.enums.MovieRating; +import java.time.LocalDate; + +public record MovieCreateRequest( + String title, + int runningTime, + LocalDate releaseDate, + MovieRating movieRating, + Genre genre, + String prologue +) { +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/movie/dto/MovieLikeResponse.java b/src/main/java/com/ceos23/cgv/domain/movie/dto/MovieLikeResponse.java new file mode 100644 index 00000000..a2c8e3e9 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/dto/MovieLikeResponse.java @@ -0,0 +1,17 @@ +package com.ceos23.cgv.domain.movie.dto; + +import com.ceos23.cgv.domain.movie.entity.MovieLike; + +public record MovieLikeResponse( + Long likeId, + Long movieId, + String movieTitle +) { + public static MovieLikeResponse from(MovieLike movieLike) { + return new MovieLikeResponse( + movieLike.getId(), + movieLike.getMovie().getId(), + movieLike.getMovie().getTitle() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/movie/dto/MovieResponse.java b/src/main/java/com/ceos23/cgv/domain/movie/dto/MovieResponse.java new file mode 100644 index 00000000..30cf9992 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/dto/MovieResponse.java @@ -0,0 +1,31 @@ +package com.ceos23.cgv.domain.movie.dto; + +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.domain.movie.enums.Genre; +import com.ceos23.cgv.domain.movie.enums.MovieRating; + +import java.time.LocalDate; + +public record MovieResponse( + Long id, + String title, + int runningTime, + Double salesRate, + LocalDate releaseDate, + MovieRating movieRating, + Genre genre, + String prologue +) { + public static MovieResponse from(Movie movie) { + return new MovieResponse( + movie.getId(), + movie.getTitle(), + movie.getRunningTime(), + movie.getSalesRate(), + movie.getReleaseDate(), + movie.getMovieRating(), + movie.getGenre(), + movie.getPrologue() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/movie/dto/ScreeningCreateRequest.java b/src/main/java/com/ceos23/cgv/domain/movie/dto/ScreeningCreateRequest.java new file mode 100644 index 00000000..8a2677c4 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/dto/ScreeningCreateRequest.java @@ -0,0 +1,12 @@ +package com.ceos23.cgv.domain.movie.dto; + +import java.time.LocalDateTime; + +public record ScreeningCreateRequest( + Long movieId, + Long theaterId, + LocalDateTime startTime, + LocalDateTime endTime, + Boolean isMorning +) { +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/movie/dto/ScreeningResponse.java b/src/main/java/com/ceos23/cgv/domain/movie/dto/ScreeningResponse.java new file mode 100644 index 00000000..a836f6a8 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/dto/ScreeningResponse.java @@ -0,0 +1,26 @@ +package com.ceos23.cgv.domain.movie.dto; + +import com.ceos23.cgv.domain.movie.entity.Screening; +import java.time.LocalDateTime; + +public record ScreeningResponse( + Long screeningId, + String movieTitle, + String cinemaName, + String theaterName, + LocalDateTime startTime, + LocalDateTime endTime, + Boolean isMorning +) { + public static ScreeningResponse from(Screening screening) { + return new ScreeningResponse( + screening.getId(), + screening.getMovie().getTitle(), + screening.getTheater().getCinema().getName(), + screening.getTheater().getName(), + screening.getStartTime(), + screening.getEndTime(), + screening.getIsMorning() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/movie/entity/Movie.java b/src/main/java/com/ceos23/cgv/domain/movie/entity/Movie.java new file mode 100644 index 00000000..344de625 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/entity/Movie.java @@ -0,0 +1,61 @@ +package com.ceos23.cgv.domain.movie.entity; + +import com.ceos23.cgv.domain.movie.enums.Genre; +import com.ceos23.cgv.domain.movie.enums.MovieRating; +import com.ceos23.cgv.global.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; + +@Entity +@Table(name = "movies", indexes = { + @Index(name = "idx_movies_sales_rate", columnList = "sales_rate"), + @Index(name = "idx_movies_release_date", columnList = "release_date") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Movie extends BaseTimeEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "movie_id") + private Long id; + + @Column(nullable = false, length = 100) + private String title; + + @Column(nullable = false) + private int runningTime; // 상영 시간(분) + + @Column(nullable = false) + private Double salesRate; //예매율 + + @Column(nullable = false) + private LocalDate releaseDate; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private MovieRating movieRating; // 관람 등급 + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Genre genre; + + @Column(columnDefinition = "TEXT") + private String prologue; + + public static Movie create(String title, int runningTime, LocalDate releaseDate, + MovieRating movieRating, Genre genre, String prologue) { + return Movie.builder() + .title(title) + .runningTime(runningTime) + .releaseDate(releaseDate) + .movieRating(movieRating) + .genre(genre) + .prologue(prologue) + .salesRate(0.0) + .build(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/movie/entity/MovieLike.java b/src/main/java/com/ceos23/cgv/domain/movie/entity/MovieLike.java new file mode 100644 index 00000000..5ae65f14 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/entity/MovieLike.java @@ -0,0 +1,33 @@ +package com.ceos23.cgv.domain.movie.entity; + +import com.ceos23.cgv.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "movie_likes") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class MovieLike { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "movie_like_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "movie_id", nullable = false) + private Movie movie; + + public static MovieLike create(User user, Movie movie) { + return MovieLike.builder() + .user(user) + .movie(movie) + .build(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/movie/entity/Screening.java b/src/main/java/com/ceos23/cgv/domain/movie/entity/Screening.java new file mode 100644 index 00000000..592b06d3 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/entity/Screening.java @@ -0,0 +1,51 @@ +package com.ceos23.cgv.domain.movie.entity; + +import com.ceos23.cgv.domain.cinema.entity.Theater; +import com.ceos23.cgv.global.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "screenings", indexes = { + @Index(name = "idx_screenings_movie_start_time", columnList = "movie_id, start_time") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Screening extends BaseTimeEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "screening_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "movie_id", nullable = false) + private Movie movie; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "theater_id", nullable = false) + private Theater theater; + + @Column(nullable = false) + private LocalDateTime startTime; + + @Column(nullable = false) + private LocalDateTime endTime; + + @Column(nullable = false) + private Boolean isMorning; //조조영화 여부 (영화마다 기준이 다르다?) + + public static Screening create(Movie movie, Theater theater, LocalDateTime startTime, + LocalDateTime endTime, Boolean isMorning) { + return Screening.builder() + .movie(movie) + .theater(theater) + .startTime(startTime) + .endTime(endTime) + .isMorning(isMorning) + .build(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/movie/enums/Genre.java b/src/main/java/com/ceos23/cgv/domain/movie/enums/Genre.java new file mode 100644 index 00000000..156dcda4 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/enums/Genre.java @@ -0,0 +1,20 @@ +package com.ceos23.cgv.domain.movie.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Genre { + ACTION("액션"), + COMEDY("코미디"), + ROMANCE("로맨스"), + THRILLER("스릴러"), + HORROR("공포"), + SF("SF"), + ANIMATION("애니메이션"), + DRAMA("드라마"), + FANTASY("판타지"); + + private final String description; +} diff --git a/src/main/java/com/ceos23/cgv/domain/movie/enums/MovieRating.java b/src/main/java/com/ceos23/cgv/domain/movie/enums/MovieRating.java new file mode 100644 index 00000000..48f93a88 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/enums/MovieRating.java @@ -0,0 +1,15 @@ +package com.ceos23.cgv.domain.movie.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MovieRating { + ALL("전체 관람가"), + AGE_12("12세 이상 관람가"), + AGE_15("15세 이상 관람가"), + AGE_19("청소년 관람불가"); + + private final String description; +} diff --git a/src/main/java/com/ceos23/cgv/domain/movie/repository/MovieLikeRepository.java b/src/main/java/com/ceos23/cgv/domain/movie/repository/MovieLikeRepository.java new file mode 100644 index 00000000..868c9216 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/repository/MovieLikeRepository.java @@ -0,0 +1,15 @@ +package com.ceos23.cgv.domain.movie.repository; + +import com.ceos23.cgv.domain.movie.entity.MovieLike; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface MovieLikeRepository extends JpaRepository { + // 유저 ID와 영화 ID로 기존에 찜을 눌렀는지 찾는 메서드 + Optional findByUserIdAndMovieId(Long userId, Long movieId); + + // 특정 유저가 찜 누른 영화 목록만 모아보기 + List findByUserId(Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/movie/repository/MovieRepository.java b/src/main/java/com/ceos23/cgv/domain/movie/repository/MovieRepository.java new file mode 100644 index 00000000..e261eb4f --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/repository/MovieRepository.java @@ -0,0 +1,19 @@ +package com.ceos23.cgv.domain.movie.repository; + +import com.ceos23.cgv.domain.movie.entity.Movie; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; + +public interface MovieRepository extends JpaRepository { + + // 1. 전체 무비차트 (기존) + List findAllByOrderBySalesRateDesc(); + + // 2. 현재 상영작 (개봉일이 오늘이거나 오늘보다 과거) - 예매율 내림차순 + List findByReleaseDateLessThanEqualOrderBySalesRateDesc(LocalDate date); + + // 3. 상영 예정작 (개봉일이 오늘보다 미래) - 예매율 내림차순 + List findByReleaseDateGreaterThanOrderBySalesRateDesc(LocalDate date); +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/movie/repository/ScreeningRepository.java b/src/main/java/com/ceos23/cgv/domain/movie/repository/ScreeningRepository.java new file mode 100644 index 00000000..88c995b4 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/repository/ScreeningRepository.java @@ -0,0 +1,20 @@ +package com.ceos23.cgv.domain.movie.repository; + +import com.ceos23.cgv.domain.movie.entity.Screening; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ScreeningRepository extends JpaRepository { + // 특정 영화의 상영 일정만 가져오는 모두 가져오는 메서드 + List findByMovieId(Long movieId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select s from Screening s where s.id = :screeningId") + Optional findByIdForUpdate(@Param("screeningId") Long screeningId); +} diff --git a/src/main/java/com/ceos23/cgv/domain/movie/service/MovieLikeService.java b/src/main/java/com/ceos23/cgv/domain/movie/service/MovieLikeService.java new file mode 100644 index 00000000..1d9e7ea0 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/service/MovieLikeService.java @@ -0,0 +1,61 @@ +package com.ceos23.cgv.domain.movie.service; + +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.domain.movie.entity.MovieLike; +import com.ceos23.cgv.domain.movie.repository.MovieLikeRepository; +import com.ceos23.cgv.domain.movie.repository.MovieRepository; +import com.ceos23.cgv.domain.user.entity.User; +import com.ceos23.cgv.domain.user.repository.UserRepository; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MovieLikeService { + + private final MovieLikeRepository movieLikeRepository; + private final UserRepository userRepository; + private final MovieRepository movieRepository; + + /** + * [POST] 영화 찜 토글 (없으면 생성, 있으면 삭제) + */ + @Transactional + public String toggleMovieLike(Long userId, Long movieId) { + User user = findUser(userId); + Movie movie = findMovie(movieId); + Optional existingLike = movieLikeRepository.findByUserIdAndMovieId(userId, movieId); + + if (existingLike.isPresent()) { + movieLikeRepository.delete(existingLike.get()); + return "찜이 취소되었습니다."; + } else { + movieLikeRepository.save(MovieLike.create(user, movie)); + return "찜이 추가되었습니다."; + } + } + + private User findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + } + + private Movie findMovie(Long movieId) { + return movieRepository.findById(movieId) + .orElseThrow(() -> new CustomException(ErrorCode.MOVIE_NOT_FOUND)); + } + + /** + * [GET] 특정 유저가 찜한 영화 목록 조회 + */ + public List getLikedMoviesByUser(Long userId) { + return movieLikeRepository.findByUserId(userId); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/movie/service/MovieService.java b/src/main/java/com/ceos23/cgv/domain/movie/service/MovieService.java new file mode 100644 index 00000000..2336bf51 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/service/MovieService.java @@ -0,0 +1,95 @@ +package com.ceos23.cgv.domain.movie.service; + +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.domain.movie.enums.Genre; +import com.ceos23.cgv.domain.movie.enums.MovieRating; +import com.ceos23.cgv.domain.movie.repository.MovieRepository; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import com.ceos23.cgv.global.cache.CacheNames; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MovieService { + + private final MovieRepository movieRepository; + + /** + * 1. 무비차트 전체 (예매율 순) + */ + @Cacheable(cacheNames = CacheNames.MOVIES, key = "'salesRate'") + public List getMoviesBySalesRate() { + return movieRepository.findAllByOrderBySalesRateDesc(); + } + + /** + * 2. 현재 상영작 탭 (이미 개봉한 영화들을 예매율 순으로 정렬) + */ + @Cacheable(cacheNames = CacheNames.MOVIES, key = "'current:' + T(java.time.LocalDate).now()") + public List getCurrentlyScreeningMovies() { + // 오늘 날짜를 기준으로 오늘 이전(오늘 포함)에 개봉한 영화들만 조회 + LocalDate today = LocalDate.now(); + return movieRepository.findByReleaseDateLessThanEqualOrderBySalesRateDesc(today); + } + + /** + * 3. 상영 예정작 탭 (아직 개봉하지 않은 영화들을 예매율 순으로 정렬) + */ + @Cacheable(cacheNames = CacheNames.MOVIES, key = "'upcoming:' + T(java.time.LocalDate).now()") + public List getUpcomingMovies() { + // 오늘 날짜를 기준으로 내일 이후에 개봉할 영화들만 조회 + LocalDate today = LocalDate.now(); + return movieRepository.findByReleaseDateGreaterThanOrderBySalesRateDesc(today); + } + + /** + * 4. 특정 영화 상세 정보 조회 + */ + @Cacheable(cacheNames = CacheNames.MOVIE_DETAILS, key = "#movieId") + public Movie getMovieDetails(Long movieId) { + return findMovie(movieId); + } + + /** + * [POST] 새로운 영화 생성 + */ + @Transactional + @CacheEvict(cacheNames = {CacheNames.MOVIES, CacheNames.MOVIE_DETAILS}, allEntries = true) + public Movie createMovie(String title, int runningTime, LocalDate releaseDate, + MovieRating movieRating, Genre genre, String prologue) { + Movie newMovie = Movie.create(title, runningTime, releaseDate, movieRating, genre, prologue); + return movieRepository.save(newMovie); + } + + /** + * [GET] 모든 영화 데이터 가져오기 + */ + @Cacheable(cacheNames = CacheNames.MOVIES, key = "'all'") + public List getAllMovies() { + return movieRepository.findAll(); + } + + /** + * [DELETE] 특정 영화 삭제 + */ + @Transactional + @CacheEvict(cacheNames = {CacheNames.MOVIES, CacheNames.MOVIE_DETAILS}, allEntries = true) + public void deleteMovie(Long movieId) { + Movie movie = findMovie(movieId); + movieRepository.delete(movie); + } + + private Movie findMovie(Long movieId) { + return movieRepository.findById(movieId) + .orElseThrow(() -> new CustomException(ErrorCode.MOVIE_NOT_FOUND)); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/movie/service/ScreeningService.java b/src/main/java/com/ceos23/cgv/domain/movie/service/ScreeningService.java new file mode 100644 index 00000000..0a04a157 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/movie/service/ScreeningService.java @@ -0,0 +1,61 @@ +package com.ceos23.cgv.domain.movie.service; + +import com.ceos23.cgv.domain.cinema.entity.Theater; +import com.ceos23.cgv.domain.cinema.repository.TheaterRepository; +import com.ceos23.cgv.domain.movie.dto.ScreeningCreateRequest; +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.domain.movie.entity.Screening; +import com.ceos23.cgv.domain.movie.repository.MovieRepository; +import com.ceos23.cgv.domain.movie.repository.ScreeningRepository; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ScreeningService { + + private final ScreeningRepository screeningRepository; + private final MovieRepository movieRepository; + private final TheaterRepository theaterRepository; + + /** + * [POST] 새로운 상영 일정 등록 + */ + @Transactional + public Screening createScreening(ScreeningCreateRequest request) { + Movie movie = findMovie(request.movieId()); + Theater theater = findTheater(request.theaterId()); + Screening screening = Screening.create( + movie, + theater, + request.startTime(), + request.endTime(), + request.isMorning() + ); + + return screeningRepository.save(screening); + } + + private Movie findMovie(Long movieId) { + return movieRepository.findById(movieId) + .orElseThrow(() -> new CustomException(ErrorCode.MOVIE_NOT_FOUND)); + } + + private Theater findTheater(Long theaterId) { + return theaterRepository.findById(theaterId) + .orElseThrow(() -> new CustomException(ErrorCode.THEATER_NOT_FOUND)); + } + + /** + * [GET] 특정 영화의 상영 일정(시간표) 조회 + */ + public List getScreeningsByMovieId(Long movieId) { + return screeningRepository.findByMovieId(movieId); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/payment/client/PaymentClient.java b/src/main/java/com/ceos23/cgv/domain/payment/client/PaymentClient.java new file mode 100644 index 00000000..62ded923 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/payment/client/PaymentClient.java @@ -0,0 +1,61 @@ +package com.ceos23.cgv.domain.payment.client; + +import com.ceos23.cgv.domain.payment.config.PaymentProperties; +import com.ceos23.cgv.domain.payment.dto.PaymentApiResponse; +import com.ceos23.cgv.domain.payment.dto.PaymentInstantRequest; +import com.ceos23.cgv.domain.payment.dto.PaymentResponse; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +@Component +@RequiredArgsConstructor +public class PaymentClient { + + private final RestClient paymentRestClient; + private final PaymentProperties paymentProperties; + + public PaymentResponse requestInstantPayment(String paymentId, PaymentInstantRequest request) { + return executePaymentRequest(() -> paymentRestClient.post() + .uri("/payments/{paymentId}/instant", paymentId) + .header(HttpHeaders.AUTHORIZATION, bearerToken()) + .body(request) + .retrieve() + .body(PaymentApiResponse.class), ErrorCode.PAYMENT_FAILED); + } + + public PaymentResponse cancelPayment(String paymentId) { + return executePaymentRequest(() -> paymentRestClient.post() + .uri("/payments/{paymentId}/cancel", paymentId) + .header(HttpHeaders.AUTHORIZATION, bearerToken()) + .retrieve() + .body(PaymentApiResponse.class), ErrorCode.PAYMENT_CANCEL_FAILED); + } + + private PaymentResponse executePaymentRequest(PaymentRequestExecutor executor, ErrorCode errorCode) { + try { + PaymentApiResponse response = executor.execute(); + if (response == null || response.payload() == null) { + throw new CustomException(errorCode); + } + return response.payload(); + } catch (CustomException e) { + throw e; + } catch (RestClientException e) { + throw new CustomException(errorCode); + } + } + + private String bearerToken() { + return "Bearer " + paymentProperties.getApiSecretKey(); + } + + @FunctionalInterface + private interface PaymentRequestExecutor { + PaymentApiResponse execute(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/payment/config/PaymentClientConfig.java b/src/main/java/com/ceos23/cgv/domain/payment/config/PaymentClientConfig.java new file mode 100644 index 00000000..4e7a82ff --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/payment/config/PaymentClientConfig.java @@ -0,0 +1,16 @@ +package com.ceos23.cgv.domain.payment.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +@Configuration +public class PaymentClientConfig { + + @Bean + public RestClient paymentRestClient(PaymentProperties paymentProperties) { + return RestClient.builder() + .baseUrl(paymentProperties.getBaseUrl()) + .build(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/payment/config/PaymentProperties.java b/src/main/java/com/ceos23/cgv/domain/payment/config/PaymentProperties.java new file mode 100644 index 00000000..f4a0dd94 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/payment/config/PaymentProperties.java @@ -0,0 +1,17 @@ +package com.ceos23.cgv.domain.payment.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "payment") +public class PaymentProperties { + + private String baseUrl; + private String storeId; + private String apiSecretKey; +} diff --git a/src/main/java/com/ceos23/cgv/domain/payment/dto/PaymentApiResponse.java b/src/main/java/com/ceos23/cgv/domain/payment/dto/PaymentApiResponse.java new file mode 100644 index 00000000..4c8d4ebf --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/payment/dto/PaymentApiResponse.java @@ -0,0 +1,8 @@ +package com.ceos23.cgv.domain.payment.dto; + +public record PaymentApiResponse( + int status, + String message, + PaymentResponse payload +) { +} diff --git a/src/main/java/com/ceos23/cgv/domain/payment/dto/PaymentInstantRequest.java b/src/main/java/com/ceos23/cgv/domain/payment/dto/PaymentInstantRequest.java new file mode 100644 index 00000000..5dc899b9 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/payment/dto/PaymentInstantRequest.java @@ -0,0 +1,10 @@ +package com.ceos23.cgv.domain.payment.dto; + +public record PaymentInstantRequest( + String storeId, + String orderName, + int totalPayAmount, + String currency, + String customData +) { +} diff --git a/src/main/java/com/ceos23/cgv/domain/payment/dto/PaymentResponse.java b/src/main/java/com/ceos23/cgv/domain/payment/dto/PaymentResponse.java new file mode 100644 index 00000000..6aeaf208 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/payment/dto/PaymentResponse.java @@ -0,0 +1,12 @@ +package com.ceos23.cgv.domain.payment.dto; + +public record PaymentResponse( + String paymentId, + String paymentStatus, + String orderName, + String pgProvider, + String currency, + String customData, + String paidAt +) { +} diff --git a/src/main/java/com/ceos23/cgv/domain/payment/service/PaymentService.java b/src/main/java/com/ceos23/cgv/domain/payment/service/PaymentService.java new file mode 100644 index 00000000..a73b3afb --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/payment/service/PaymentService.java @@ -0,0 +1,83 @@ +package com.ceos23.cgv.domain.payment.service; + +import com.ceos23.cgv.domain.concession.entity.FoodOrder; +import com.ceos23.cgv.domain.payment.client.PaymentClient; +import com.ceos23.cgv.domain.payment.config.PaymentProperties; +import com.ceos23.cgv.domain.payment.dto.PaymentInstantRequest; +import com.ceos23.cgv.domain.payment.dto.PaymentResponse; +import com.ceos23.cgv.domain.reservation.entity.Reservation; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class PaymentService { + + private static final String CURRENCY_KRW = "KRW"; + private static final String RESERVATION_ORDER_NAME = "CGV 영화 예매"; + private static final String FOOD_ORDER_NAME = "CGV 매점 주문"; + + private final PaymentClient paymentClient; + private final PaymentProperties paymentProperties; + + public String createPaymentId() { + return "reservation-" + UUID.randomUUID(); + } + + public String createFoodOrderPaymentId() { + return "food-order-" + UUID.randomUUID(); + } + + public PaymentResponse requestInstantPayment(Reservation reservation) { + PaymentInstantRequest request = createInstantRequest( + RESERVATION_ORDER_NAME, + reservation.getPrice(), + createCustomData(reservation) + ); + + return paymentClient.requestInstantPayment(reservation.getPaymentId(), request); + } + + public PaymentResponse requestInstantPayment(FoodOrder foodOrder) { + PaymentInstantRequest request = createInstantRequest( + FOOD_ORDER_NAME, + foodOrder.getTotalPrice(), + createCustomData(foodOrder) + ); + + return paymentClient.requestInstantPayment(foodOrder.getPaymentId(), request); + } + + public PaymentResponse cancelPayment(String paymentId) { + return paymentClient.cancelPayment(paymentId); + } + + private PaymentInstantRequest createInstantRequest(String orderName, int totalPayAmount, String customData) { + return new PaymentInstantRequest( + paymentProperties.getStoreId(), + orderName, + totalPayAmount, + CURRENCY_KRW, + customData + ); + } + + private String createCustomData(Reservation reservation) { + return String.format( + "{\"reservationId\":%d,\"screeningId\":%d,\"saleNumber\":\"%s\"}", + reservation.getId(), + reservation.getScreening().getId(), + reservation.getSaleNumber() + ); + } + + private String createCustomData(FoodOrder foodOrder) { + return String.format( + "{\"foodOrderId\":%d,\"cinemaId\":%d}", + foodOrder.getId(), + foodOrder.getCinema().getId() + ); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/person/controller/PersonController.java b/src/main/java/com/ceos23/cgv/domain/person/controller/PersonController.java new file mode 100644 index 00000000..eb710163 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/person/controller/PersonController.java @@ -0,0 +1,40 @@ +package com.ceos23.cgv.domain.person.controller; + +import com.ceos23.cgv.domain.person.dto.PersonWorkResponse; +import com.ceos23.cgv.domain.person.dto.WorkParticipationResponse; +import com.ceos23.cgv.domain.person.service.PersonService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/persons") +@RequiredArgsConstructor +@Tag(name = "Person API", description = "영화 인물(배우/감독) 등록 및 참여작 관리 API") +public class PersonController { + + private final PersonService personService; + + @GetMapping("/movie/{movieId}") + @Operation(summary = "특정 영화의 감독/출연진 조회", description = "영화 ID를 통해 해당 영화에 참여한 모든 인물(주연, 감독 등) 목록을 가져옵니다.") + public ResponseEntity> getParticipantsByMovie(@PathVariable Long movieId) { + List responses = personService.getParticipantsByMovieId(movieId).stream() + .map(WorkParticipationResponse::from) + .collect(Collectors.toList()); + return ResponseEntity.ok(responses); + } + + @GetMapping("/{personId}/movies") + @Operation(summary = "특정 인물의 필모그래피(출연작) 조회", description = "인물 ID를 통해 해당 배우/감독이 참여한 모든 영화 목록과 역할을 조회합니다.") + public ResponseEntity> getMoviesByPerson(@PathVariable Long personId) { + List responses = personService.getParticipationsByPersonId(personId).stream() + .map(PersonWorkResponse::from) + .collect(Collectors.toList()); + return ResponseEntity.ok(responses); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/person/controller/admin/AdminPersonController.java b/src/main/java/com/ceos23/cgv/domain/person/controller/admin/AdminPersonController.java new file mode 100644 index 00000000..885691e3 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/person/controller/admin/AdminPersonController.java @@ -0,0 +1,41 @@ +package com.ceos23.cgv.domain.person.controller.admin; + +import com.ceos23.cgv.domain.person.dto.PersonCreateRequest; +import com.ceos23.cgv.domain.person.dto.WorkParticipationRequest; +import com.ceos23.cgv.domain.person.dto.WorkParticipationResponse; +import com.ceos23.cgv.domain.person.entity.Person; +import com.ceos23.cgv.domain.person.entity.WorkParticipation; +import com.ceos23.cgv.domain.person.service.PersonService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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/admin/persons") +@RequiredArgsConstructor +@Tag(name = "Admin Person API", description = "관리자 전용 영화 인물 및 참여작 관리 API") +public class AdminPersonController { + + private final PersonService personService; + + @PostMapping + @Operation(summary = "인물 등록", description = "새로운 배우, 감독 등의 인물 정보를 DB에 등록합니다.") + public ResponseEntity createPerson(@RequestBody PersonCreateRequest request) { + Person person = personService.createPerson(request); + return ResponseEntity.status(HttpStatus.CREATED).body(person); + } + + @PostMapping("/participations") + @Operation(summary = "영화 참여 정보 등록", description = "특정 인물을 특정 영화의 주연, 조연, 감독 등으로 연결합니다.") + public ResponseEntity addParticipation(@RequestBody WorkParticipationRequest request) { + WorkParticipation participation = personService.addWorkParticipation(request); + return ResponseEntity.status(HttpStatus.CREATED) + .body(WorkParticipationResponse.from(participation)); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/person/dto/PersonCreateRequest.java b/src/main/java/com/ceos23/cgv/domain/person/dto/PersonCreateRequest.java new file mode 100644 index 00000000..903fad59 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/person/dto/PersonCreateRequest.java @@ -0,0 +1,13 @@ +package com.ceos23.cgv.domain.person.dto; + +import com.ceos23.cgv.domain.person.enums.PersonType; +import java.time.LocalDate; + +public record PersonCreateRequest( + PersonType type, + String name, + String englishName, + LocalDate birthDate, + String award +) { +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/person/dto/PersonWorkResponse.java b/src/main/java/com/ceos23/cgv/domain/person/dto/PersonWorkResponse.java new file mode 100644 index 00000000..07cd43c2 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/person/dto/PersonWorkResponse.java @@ -0,0 +1,20 @@ +package com.ceos23.cgv.domain.person.dto; + +import com.ceos23.cgv.domain.person.entity.WorkParticipation; +import com.ceos23.cgv.domain.person.enums.RoleType; + +public record PersonWorkResponse( + Long participationId, + Long movieId, + String movieTitle, + RoleType role +) { + public static PersonWorkResponse from(WorkParticipation participation) { + return new PersonWorkResponse( + participation.getId(), + participation.getMovie().getId(), + participation.getMovie().getTitle(), + participation.getRole() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/person/dto/WorkParticipationRequest.java b/src/main/java/com/ceos23/cgv/domain/person/dto/WorkParticipationRequest.java new file mode 100644 index 00000000..d696c517 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/person/dto/WorkParticipationRequest.java @@ -0,0 +1,10 @@ +package com.ceos23.cgv.domain.person.dto; + +import com.ceos23.cgv.domain.person.enums.RoleType; + +public record WorkParticipationRequest( + Long movieId, + Long personId, + RoleType role +) { +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/person/dto/WorkParticipationResponse.java b/src/main/java/com/ceos23/cgv/domain/person/dto/WorkParticipationResponse.java new file mode 100644 index 00000000..15fbc2bd --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/person/dto/WorkParticipationResponse.java @@ -0,0 +1,23 @@ +package com.ceos23.cgv.domain.person.dto; + +import com.ceos23.cgv.domain.person.entity.WorkParticipation; +import com.ceos23.cgv.domain.person.enums.PersonType; +import com.ceos23.cgv.domain.person.enums.RoleType; + +public record WorkParticipationResponse( + Long participationId, + Long personId, + String personName, + PersonType personType, + RoleType role +) { + public static WorkParticipationResponse from(WorkParticipation participation) { + return new WorkParticipationResponse( + participation.getId(), + participation.getPerson().getId(), + participation.getPerson().getName(), + participation.getPerson().getType(), + participation.getRole() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/person/entity/Person.java b/src/main/java/com/ceos23/cgv/domain/person/entity/Person.java new file mode 100644 index 00000000..e77af54b --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/person/entity/Person.java @@ -0,0 +1,46 @@ +package com.ceos23.cgv.domain.person.entity; + +import com.ceos23.cgv.domain.person.enums.PersonType; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; + +@Entity +@Table(name = "persons") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Person { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "actor_id") + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PersonType type; + + @Column(nullable = false, length = 50) + private String name; + + @Column(length = 50) + private String englishName; + + private LocalDate birthDate; + + @Column(columnDefinition = "TEXT") + private String award; //수상내역 + + public static Person create(PersonType type, String name, String englishName, + LocalDate birthDate, String award) { + return Person.builder() + .type(type) + .name(name) + .englishName(englishName) + .birthDate(birthDate) + .award(award) + .build(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/person/entity/WorkParticipation.java b/src/main/java/com/ceos23/cgv/domain/person/entity/WorkParticipation.java new file mode 100644 index 00000000..a5bf1968 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/person/entity/WorkParticipation.java @@ -0,0 +1,39 @@ +package com.ceos23.cgv.domain.person.entity; + +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.domain.person.enums.RoleType; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "work_participations") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class WorkParticipation { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "work_participation_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "movie_id", nullable = false) + private Movie movie; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "actor_id", nullable = false) + private Person person; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private RoleType role; + + public static WorkParticipation create(Movie movie, Person person, RoleType role) { + return WorkParticipation.builder() + .movie(movie) + .person(person) + .role(role) + .build(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/person/enums/PersonType.java b/src/main/java/com/ceos23/cgv/domain/person/enums/PersonType.java new file mode 100644 index 00000000..1a80102f --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/person/enums/PersonType.java @@ -0,0 +1,15 @@ +package com.ceos23.cgv.domain.person.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PersonType { + ACTOR("배우"), + DIRECTOR("감독"), + WRITER("작가"), + PRODUCER("제작자"); + + private final String description; +} diff --git a/src/main/java/com/ceos23/cgv/domain/person/enums/RoleType.java b/src/main/java/com/ceos23/cgv/domain/person/enums/RoleType.java new file mode 100644 index 00000000..a2c17302 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/person/enums/RoleType.java @@ -0,0 +1,15 @@ +package com.ceos23.cgv.domain.person.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum RoleType { + LEAD_ACTOR("주연"), + SUPPORTING_ACTOR("조연"), + CAMEO("카메오"), + DIRECTOR("감독"); + + private final String description; +} diff --git a/src/main/java/com/ceos23/cgv/domain/person/repository/PersonRepository.java b/src/main/java/com/ceos23/cgv/domain/person/repository/PersonRepository.java new file mode 100644 index 00000000..1e8b8acf --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/person/repository/PersonRepository.java @@ -0,0 +1,7 @@ +package com.ceos23.cgv.domain.person.repository; + +import com.ceos23.cgv.domain.person.entity.Person; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PersonRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/person/repository/WorkParticipationRepository.java b/src/main/java/com/ceos23/cgv/domain/person/repository/WorkParticipationRepository.java new file mode 100644 index 00000000..82c3c3f7 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/person/repository/WorkParticipationRepository.java @@ -0,0 +1,14 @@ +package com.ceos23.cgv.domain.person.repository; + +import com.ceos23.cgv.domain.person.entity.WorkParticipation; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface WorkParticipationRepository extends JpaRepository { + // 특정 영화에 참여한 인물(감독/배우) 목록 조회 + List findByMovieId(Long movieId); + + // 특정 인물(배우/감독)이 참여한 작품 목록 + List findByPersonId(Long personId); +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/person/service/PersonService.java b/src/main/java/com/ceos23/cgv/domain/person/service/PersonService.java new file mode 100644 index 00000000..63119f3c --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/person/service/PersonService.java @@ -0,0 +1,79 @@ +package com.ceos23.cgv.domain.person.service; + +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.domain.movie.repository.MovieRepository; +import com.ceos23.cgv.domain.person.dto.PersonCreateRequest; +import com.ceos23.cgv.domain.person.dto.WorkParticipationRequest; +import com.ceos23.cgv.domain.person.entity.Person; +import com.ceos23.cgv.domain.person.entity.WorkParticipation; +import com.ceos23.cgv.domain.person.repository.PersonRepository; +import com.ceos23.cgv.domain.person.repository.WorkParticipationRepository; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PersonService { + + private final PersonRepository personRepository; + private final WorkParticipationRepository workParticipationRepository; + private final MovieRepository movieRepository; + + /** + * [POST] 새로운 영화 인물(배우, 감독 등) 등록 + */ + @Transactional + public Person createPerson(PersonCreateRequest request) { + Person person = Person.create( + request.type(), + request.name(), + request.englishName(), + request.birthDate(), + request.award() + ); + + return personRepository.save(person); + } + + /** + * [POST] 인물을 특정 영화에 참여시키기 + */ + @Transactional + public WorkParticipation addWorkParticipation(WorkParticipationRequest request) { + Movie movie = findMovie(request.movieId()); + Person person = findPerson(request.personId()); + WorkParticipation participation = WorkParticipation.create(movie, person, request.role()); + + return workParticipationRepository.save(participation); + } + + private Movie findMovie(Long movieId) { + return movieRepository.findById(movieId) + .orElseThrow(() -> new CustomException(ErrorCode.MOVIE_NOT_FOUND)); + } + + private Person findPerson(Long personId) { + return personRepository.findById(personId) + .orElseThrow(() -> new CustomException(ErrorCode.PERSON_NOT_FOUND)); + } + + /** + * [GET] 특정 영화의 감독/출연진 목록 조회 + */ + public List getParticipantsByMovieId(Long movieId) { + return workParticipationRepository.findByMovieId(movieId); + } + + /** + * [GET] 특정 인물(배우/감독)의 참여 영화 목록(필모그래피) 조회 + */ + public List getParticipationsByPersonId(Long personId) { + return workParticipationRepository.findByPersonId(personId); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/photo/controller/PhotoController.java b/src/main/java/com/ceos23/cgv/domain/photo/controller/PhotoController.java new file mode 100644 index 00000000..a57c35f6 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/photo/controller/PhotoController.java @@ -0,0 +1,39 @@ +package com.ceos23.cgv.domain.photo.controller; + +import com.ceos23.cgv.domain.photo.dto.PhotoResponse; +import com.ceos23.cgv.domain.photo.service.PhotoService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/photos") +@RequiredArgsConstructor +@Tag(name = "Photo API", description = "영화 및 인물 사진 등록 및 조회 API") +public class PhotoController { + + private final PhotoService photoService; + + @GetMapping("/movie/{movieId}") + @Operation(summary = "특정 영화의 사진 조회", description = "영화 ID를 통해 해당 영화의 스틸컷/포스터 목록을 불러옵니다.") + public ResponseEntity> getPhotosByMovie(@PathVariable Long movieId) { + List responses = photoService.getPhotosByMovieId(movieId).stream() + .map(PhotoResponse::from) + .collect(Collectors.toList()); + return ResponseEntity.ok(responses); + } + + @GetMapping("/person/{personId}") + @Operation(summary = "특정 인물의 사진 조회", description = "인물 ID를 통해 해당 배우/감독의 사진 목록을 불러옵니다.") + public ResponseEntity> getPhotosByPerson(@PathVariable Long personId) { + List responses = photoService.getPhotosByPersonId(personId).stream() + .map(PhotoResponse::from) + .collect(Collectors.toList()); + return ResponseEntity.ok(responses); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/photo/controller/admin/AdminPhotoController.java b/src/main/java/com/ceos23/cgv/domain/photo/controller/admin/AdminPhotoController.java new file mode 100644 index 00000000..56604b8d --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/photo/controller/admin/AdminPhotoController.java @@ -0,0 +1,31 @@ +package com.ceos23.cgv.domain.photo.controller.admin; + +import com.ceos23.cgv.domain.photo.dto.PhotoCreateRequest; +import com.ceos23.cgv.domain.photo.dto.PhotoResponse; +import com.ceos23.cgv.domain.photo.entity.Photo; +import com.ceos23.cgv.domain.photo.service.PhotoService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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/admin/photos") +@RequiredArgsConstructor +@Tag(name = "Admin Photo API", description = "관리자 전용 영화 및 인물 사진 관리 API") +public class AdminPhotoController { + + private final PhotoService photoService; + + @PostMapping + @Operation(summary = "사진 등록", description = "영화 또는 인물에 연관된 사진 URL(name)을 등록합니다. (movieId나 personId 중 하나 필수)") + public ResponseEntity createPhoto(@RequestBody PhotoCreateRequest request) { + Photo photo = photoService.createPhoto(request); + return ResponseEntity.status(HttpStatus.CREATED).body(PhotoResponse.from(photo)); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/photo/dto/PhotoCreateRequest.java b/src/main/java/com/ceos23/cgv/domain/photo/dto/PhotoCreateRequest.java new file mode 100644 index 00000000..6730d36a --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/photo/dto/PhotoCreateRequest.java @@ -0,0 +1,17 @@ +package com.ceos23.cgv.domain.photo.dto; + +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; + +public record PhotoCreateRequest( + Long movieId, + Long personId, + String name +) { + // 최소 하나는 존재해야 하며, 둘 다 있는 것도 허용 + public PhotoCreateRequest { + if (movieId == null && personId == null) { + throw new CustomException(ErrorCode.PHOTO_TARGET_REQUIRED); + } + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/photo/dto/PhotoResponse.java b/src/main/java/com/ceos23/cgv/domain/photo/dto/PhotoResponse.java new file mode 100644 index 00000000..4ef5b4b9 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/photo/dto/PhotoResponse.java @@ -0,0 +1,19 @@ +package com.ceos23.cgv.domain.photo.dto; + +import com.ceos23.cgv.domain.photo.entity.Photo; + +public record PhotoResponse( + Long photoId, + String name, + Long movieId, + Long personId +) { + public static PhotoResponse from(Photo photo) { + return new PhotoResponse( + photo.getId(), + photo.getName(), + photo.getMovie() != null ? photo.getMovie().getId() : null, + photo.getPerson() != null ? photo.getPerson().getId() : null + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/photo/entity/Photo.java b/src/main/java/com/ceos23/cgv/domain/photo/entity/Photo.java new file mode 100644 index 00000000..69aa3aa9 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/photo/entity/Photo.java @@ -0,0 +1,41 @@ +package com.ceos23.cgv.domain.photo.entity; + +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.domain.person.entity.Person; +import com.ceos23.cgv.global.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "photos") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Photo extends BaseTimeEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "photo_id") + private Long id; + + @Column(nullable = false, length = 255) + private String name; // 사진 파일명 또는 url + + // nullable = true, 영화 사진일 땐 actor가 null + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "actor_id") + private Person person; + + // nullable = true, 인물 사진일 땐 movie가 null + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "movie_id") + private Movie movie; + + public static Photo create(String name, Movie movie, Person person) { + return Photo.builder() + .name(name) + .movie(movie) + .person(person) + .build(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/photo/repository/PhotoRepository.java b/src/main/java/com/ceos23/cgv/domain/photo/repository/PhotoRepository.java new file mode 100644 index 00000000..027e1370 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/photo/repository/PhotoRepository.java @@ -0,0 +1,13 @@ +package com.ceos23.cgv.domain.photo.repository; + +import com.ceos23.cgv.domain.photo.entity.Photo; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface PhotoRepository extends JpaRepository { + // 1. 특정 영화의 사진 목록 조회 + List findByMovieId(Long movieId); + + // 2. 특정 인물(배우/감독)의 사진 목록 조회 추가 + List findByPersonId(Long personId); +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/photo/service/PhotoService.java b/src/main/java/com/ceos23/cgv/domain/photo/service/PhotoService.java new file mode 100644 index 00000000..a52402e7 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/photo/service/PhotoService.java @@ -0,0 +1,70 @@ +package com.ceos23.cgv.domain.photo.service; + +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.domain.movie.repository.MovieRepository; +import com.ceos23.cgv.domain.person.entity.Person; +import com.ceos23.cgv.domain.person.repository.PersonRepository; +import com.ceos23.cgv.domain.photo.dto.PhotoCreateRequest; +import com.ceos23.cgv.domain.photo.entity.Photo; +import com.ceos23.cgv.domain.photo.repository.PhotoRepository; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PhotoService { + + private final PhotoRepository photoRepository; + private final MovieRepository movieRepository; + private final PersonRepository personRepository; + + /** + * [POST] 사진 등록 (영화 사진 or 인물 사진) + */ + @Transactional + public Photo createPhoto(PhotoCreateRequest request) { + Movie movie = findMovieOrNull(request.movieId()); + Person person = findPersonOrNull(request.personId()); + Photo photo = Photo.create(request.name(), movie, person); + + return photoRepository.save(photo); + } + + private Movie findMovieOrNull(Long movieId) { + if (movieId == null) { + return null; + } + + return movieRepository.findById(movieId) + .orElseThrow(() -> new CustomException(ErrorCode.MOVIE_NOT_FOUND)); + } + + private Person findPersonOrNull(Long personId) { + if (personId == null) { + return null; + } + + return personRepository.findById(personId) + .orElseThrow(() -> new CustomException(ErrorCode.PERSON_NOT_FOUND)); + } + + /** + * [GET] 특정 영화의 사진 목록 조회 + */ + public List getPhotosByMovieId(Long movieId) { + return photoRepository.findByMovieId(movieId); + } + + /** + * [GET] 특정 인물의 사진 목록 조회 + */ + public List getPhotosByPersonId(Long personId) { + return photoRepository.findByPersonId(personId); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/reservation/controller/ReservationController.java b/src/main/java/com/ceos23/cgv/domain/reservation/controller/ReservationController.java new file mode 100644 index 00000000..4f504f61 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/reservation/controller/ReservationController.java @@ -0,0 +1,57 @@ +package com.ceos23.cgv.domain.reservation.controller; + +import com.ceos23.cgv.domain.reservation.dto.ReservationCreateRequest; +import com.ceos23.cgv.domain.reservation.dto.ReservationResponse; +import com.ceos23.cgv.domain.reservation.entity.Reservation; +import com.ceos23.cgv.domain.reservation.service.ReservationService; +import com.ceos23.cgv.global.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/reservations") +@RequiredArgsConstructor +@Tag(name = "Reservation API", description = "영화 예매 및 취소 API") +public class ReservationController { + + private final ReservationService reservationService; + + @PostMapping + @Operation(summary = "영화 예매하기", description = "상영 일정, 인원수, 결제 수단, 좌석 정보를 입력받아 예매를 진행합니다.") + public ResponseEntity> createReservation( + @RequestBody ReservationCreateRequest request, + @AuthenticationPrincipal UserDetails userDetails) { + + Long userId = Long.parseLong(userDetails.getUsername()); + + Reservation reservation = reservationService.createReservation( + userId, + request.screeningId(), + request.peopleCount(), + request.payment(), + request.couponCode(), + request.seats() + ); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.created(ReservationResponse.from(reservation))); + } + + @PatchMapping("/{reservationId}/cancel") + @Operation(summary = "영화 예매 취소", description = "예매 ID와 유저 ID를 받아 본인의 예매 내역을 취소(CANCELED) 상태로 변경합니다.") + public ResponseEntity> cancelReservation( + @PathVariable Long reservationId, + @AuthenticationPrincipal UserDetails userDetails) { + + Long userId = Long.parseLong(userDetails.getUsername()); + reservationService.cancelReservation(userId, reservationId); + + return ResponseEntity.ok(ApiResponse.success("예매가 성공적으로 취소되었습니다.")); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/reservation/controller/ReservedSeatController.java b/src/main/java/com/ceos23/cgv/domain/reservation/controller/ReservedSeatController.java new file mode 100644 index 00000000..0bb1d7fc --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/reservation/controller/ReservedSeatController.java @@ -0,0 +1,30 @@ +package com.ceos23.cgv.domain.reservation.controller; + +import com.ceos23.cgv.domain.reservation.dto.ReservedSeatResponse; +import com.ceos23.cgv.domain.reservation.service.ReservedSeatService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/reserved-seats") +@RequiredArgsConstructor +@Tag(name = "Reserved Seat API", description = "예매 좌석 선점 및 조회 API") +public class ReservedSeatController { + + private final ReservedSeatService reservedSeatService; + + @GetMapping("/screening/{screeningId}") + @Operation(summary = "예매 완료된 좌석 조회", description = "특정 상영 시간표(Screening)에 이미 예매가 끝난 좌석 행/열 목록을 불러옵니다.") + public ResponseEntity> getReservedSeats(@PathVariable Long screeningId) { + List responses = reservedSeatService.getReservedSeatsByScreeningId(screeningId).stream() + .map(ReservedSeatResponse::from) + .collect(Collectors.toList()); + return ResponseEntity.ok(responses); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/reservation/dto/ReservationCreateRequest.java b/src/main/java/com/ceos23/cgv/domain/reservation/dto/ReservationCreateRequest.java new file mode 100644 index 00000000..ef64bf29 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/reservation/dto/ReservationCreateRequest.java @@ -0,0 +1,14 @@ +package com.ceos23.cgv.domain.reservation.dto; + +import com.ceos23.cgv.domain.reservation.enums.Payment; + +import java.util.List; + +public record ReservationCreateRequest( + Long screeningId, + int peopleCount, + Payment payment, + String couponCode, + List seats +) { +} diff --git a/src/main/java/com/ceos23/cgv/domain/reservation/dto/ReservationResponse.java b/src/main/java/com/ceos23/cgv/domain/reservation/dto/ReservationResponse.java new file mode 100644 index 00000000..625df28d --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/reservation/dto/ReservationResponse.java @@ -0,0 +1,33 @@ +package com.ceos23.cgv.domain.reservation.dto; + +import com.ceos23.cgv.domain.reservation.entity.Reservation; +import com.ceos23.cgv.domain.reservation.enums.Payment; +import com.ceos23.cgv.domain.reservation.enums.ReservationStatus; + +public record ReservationResponse( + Long reservationId, + String userName, + String movieTitle, + String cinemaName, + String theaterName, + ReservationStatus status, + int peopleCount, + int price, + Payment payment, + String saleNumber +) { + public static ReservationResponse from(Reservation reservation) { + return new ReservationResponse( + reservation.getId(), + reservation.getUser().getName(), + reservation.getScreening().getMovie().getTitle(), + reservation.getScreening().getTheater().getCinema().getName(), + reservation.getScreening().getTheater().getName(), + reservation.getStatus(), + reservation.getPeopleCount(), + reservation.getPrice(), + reservation.getPayment(), + reservation.getSaleNumber() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/reservation/dto/ReservedSeatRequest.java b/src/main/java/com/ceos23/cgv/domain/reservation/dto/ReservedSeatRequest.java new file mode 100644 index 00000000..c6a3f69d --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/reservation/dto/ReservedSeatRequest.java @@ -0,0 +1,14 @@ +package com.ceos23.cgv.domain.reservation.dto; + +import java.util.List; + +public record ReservedSeatRequest( + Long reservationId, + Long screeningId, + List seats +) { + public record SeatInfo( + String row, + int col + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/reservation/dto/ReservedSeatResponse.java b/src/main/java/com/ceos23/cgv/domain/reservation/dto/ReservedSeatResponse.java new file mode 100644 index 00000000..ca4c39fa --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/reservation/dto/ReservedSeatResponse.java @@ -0,0 +1,19 @@ +package com.ceos23.cgv.domain.reservation.dto; + +import com.ceos23.cgv.domain.reservation.entity.ReservedSeat; + +public record ReservedSeatResponse( + Long reservedSeatId, + String seatRow, + int seatCol, + Long screeningId +) { + public static ReservedSeatResponse from(ReservedSeat reservedSeat) { + return new ReservedSeatResponse( + reservedSeat.getId(), + reservedSeat.getSeatRow(), + reservedSeat.getSeatCol(), + reservedSeat.getScreening().getId() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/reservation/entity/Reservation.java b/src/main/java/com/ceos23/cgv/domain/reservation/entity/Reservation.java new file mode 100644 index 00000000..c3af0fae --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/reservation/entity/Reservation.java @@ -0,0 +1,104 @@ +package com.ceos23.cgv.domain.reservation.entity; + +import com.ceos23.cgv.domain.movie.entity.Screening; +import com.ceos23.cgv.domain.reservation.enums.Payment; +import com.ceos23.cgv.domain.reservation.enums.ReservationStatus; +import com.ceos23.cgv.domain.user.entity.User; +import com.ceos23.cgv.global.entity.BaseTimeEntity; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "reservations") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Reservation extends BaseTimeEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "reservation_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "screening_id", nullable = false) + private Screening screening; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ReservationStatus status; + + @Column(nullable = false) + private int peopleCount; //누적 관객 수 + + @Column(nullable = false) + private int price; // 결제 금액 + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Payment payment; + + @Column(length = 50) + private String coupon; + + @Column(nullable = false, unique = true, length = 50) + private String saleNumber; // 예매 번호 + + @Column(nullable = false, unique = true, length = 100) + private String paymentId; + + public static Reservation create(User user, Screening screening, int peopleCount, + int price, Payment payment, String coupon, String saleNumber, + String paymentId) { + return Reservation.builder() + .user(user) + .screening(screening) + .status(ReservationStatus.PENDING) + .peopleCount(peopleCount) + .price(price) + .payment(payment) + .coupon(coupon) + .saleNumber(saleNumber) + .paymentId(paymentId) + .build(); + } + + public void validateCancelableBy(Long userId) { + if (!this.user.getId().equals(userId)) { + throw new CustomException(ErrorCode.RESERVATION_ACCESS_DENIED); + } + + if (this.status == ReservationStatus.CANCELED) { + throw new CustomException(ErrorCode.RESERVATION_ALREADY_CANCELED); + } + } + + public void validatePaymentCompleted() { + if (this.status != ReservationStatus.COMPLETED) { + throw new CustomException(ErrorCode.PAYMENT_NOT_COMPLETED); + } + + if (this.paymentId == null || this.paymentId.isBlank()) { + throw new CustomException(ErrorCode.PAYMENT_NOT_FOUND); + } + } + + public void completePayment() { + if (this.status != ReservationStatus.PENDING) { + throw new CustomException(ErrorCode.PAYMENT_NOT_COMPLETED); + } + + this.status = ReservationStatus.COMPLETED; + } + + // 예매 취소 메서드 + public void cancel() { + this.status = ReservationStatus.CANCELED; + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/reservation/entity/ReservedSeat.java b/src/main/java/com/ceos23/cgv/domain/reservation/entity/ReservedSeat.java new file mode 100644 index 00000000..6de6c5af --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/reservation/entity/ReservedSeat.java @@ -0,0 +1,47 @@ +package com.ceos23.cgv.domain.reservation.entity; + +import com.ceos23.cgv.domain.movie.entity.Screening; +import jakarta.persistence.*; +import lombok.*; + +@Entity +// 같은 상영일정에 같은 좌석이 중복 저장되는 것을 DB단에서 차단 +@Table(name = "reserved_seats", uniqueConstraints = { + @UniqueConstraint( + name = "uk_screening_seat", + columnNames = {"screening_id", "seat_row", "seat_col"} + ) +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class ReservedSeat { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "reserved_seat_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reservation_id", nullable = false) + private Reservation reservation; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "screening_id", nullable = false) + private Screening screening; + + @Column(name = "seat_row", nullable = false, length = 2) + private String seatRow; + + @Column(name = "seat_col", nullable = false) + private int seatCol; + + public static ReservedSeat create(Reservation reservation, Screening screening, String seatRow, int seatCol) { + return ReservedSeat.builder() + .reservation(reservation) + .screening(screening) + .seatRow(seatRow) + .seatCol(seatCol) + .build(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/reservation/enums/Coupon.java b/src/main/java/com/ceos23/cgv/domain/reservation/enums/Coupon.java new file mode 100644 index 00000000..a93346b2 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/reservation/enums/Coupon.java @@ -0,0 +1,30 @@ +package com.ceos23.cgv.domain.reservation.enums; + +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; + +import java.util.Arrays; +import java.util.function.IntUnaryOperator; + +public enum Coupon { + + WELCOME_CGV(price -> price - 3000), + VIP_HALF_PRICE(price -> price - price / 2); + + private final IntUnaryOperator discountPolicy; + + Coupon(IntUnaryOperator discountPolicy) { + this.discountPolicy = discountPolicy; + } + + public static Coupon from(String couponCode) { + return Arrays.stream(values()) + .filter(coupon -> coupon.name().equals(couponCode)) + .findFirst() + .orElseThrow(() -> new CustomException(ErrorCode.INVALID_COUPON_CODE)); + } + + public int applyDiscount(int price) { + return Math.max(discountPolicy.applyAsInt(price), 0); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/reservation/enums/Payment.java b/src/main/java/com/ceos23/cgv/domain/reservation/enums/Payment.java new file mode 100644 index 00000000..cac7f4dd --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/reservation/enums/Payment.java @@ -0,0 +1,23 @@ +package com.ceos23.cgv.domain.reservation.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Payment { + APP_CARD("앱카드"), + MOBILE("휴대폰 결제"), + BANK_ACCOUNT("계좌이체"), + KAKAO_PAY("카카오페이"), + NAVER_PAY("네이버페이"), + TOSS("토스페이"), + POINT("포인트 결제"), + EZWEL_PAY("이지웰페이"), + SMILE_PAY("스마일페이"), + PAYCO("페이코"), + SSG_PAY("SSG페이"), + CJ_PAY("CJ ONE 페이"); + + private final String description; +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/reservation/enums/ReservationStatus.java b/src/main/java/com/ceos23/cgv/domain/reservation/enums/ReservationStatus.java new file mode 100644 index 00000000..81da1a73 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/reservation/enums/ReservationStatus.java @@ -0,0 +1,15 @@ +package com.ceos23.cgv.domain.reservation.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ReservationStatus { + PENDING("결제 대기"), + BOOKED("예매 완료"), + CANCELED("예매 취소"), + COMPLETED("결제 완료"); + + private final String description; +} diff --git a/src/main/java/com/ceos23/cgv/domain/reservation/policy/CouponDiscountPolicy.java b/src/main/java/com/ceos23/cgv/domain/reservation/policy/CouponDiscountPolicy.java new file mode 100644 index 00000000..775fcc55 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/reservation/policy/CouponDiscountPolicy.java @@ -0,0 +1,21 @@ +package com.ceos23.cgv.domain.reservation.policy; + +import com.ceos23.cgv.domain.reservation.enums.Coupon; + +public final class CouponDiscountPolicy { + + private CouponDiscountPolicy() { + } + + public static int apply(int currentPrice, String couponCode) { + if (!hasCoupon(couponCode)) { + return currentPrice; + } + + return Coupon.from(couponCode).applyDiscount(currentPrice); + } + + private static boolean hasCoupon(String couponCode) { + return couponCode != null && !couponCode.isBlank(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/reservation/policy/ReservationPricePolicy.java b/src/main/java/com/ceos23/cgv/domain/reservation/policy/ReservationPricePolicy.java new file mode 100644 index 00000000..4525c164 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/reservation/policy/ReservationPricePolicy.java @@ -0,0 +1,26 @@ +package com.ceos23.cgv.domain.reservation.policy; + +import com.ceos23.cgv.domain.movie.entity.Screening; + +public final class ReservationPricePolicy { + + private static final int MORNING_DISCOUNT = 4000; + + private ReservationPricePolicy() { + } + + public static int calculate(Screening screening, int peopleCount, String couponCode) { + int calculatedPrice = calculateTicketPrice(screening) * peopleCount; + return CouponDiscountPolicy.apply(calculatedPrice, couponCode); + } + + private static int calculateTicketPrice(Screening screening) { + int ticketPrice = screening.getTheater().getType().getBasePrice(); + + if (Boolean.TRUE.equals(screening.getIsMorning())) { + return ticketPrice - MORNING_DISCOUNT; + } + + return ticketPrice; + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/reservation/repository/ReservationRepository.java b/src/main/java/com/ceos23/cgv/domain/reservation/repository/ReservationRepository.java new file mode 100644 index 00000000..7c740657 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/reservation/repository/ReservationRepository.java @@ -0,0 +1,12 @@ +package com.ceos23.cgv.domain.reservation.repository; + +import com.ceos23.cgv.domain.reservation.entity.Reservation; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface ReservationRepository extends JpaRepository { + // 예매 번호로 예매 내역 조회 + Optional findBySaleNumber(String saleNumber); + + Optional findByPaymentId(String paymentId); +} diff --git a/src/main/java/com/ceos23/cgv/domain/reservation/repository/ReservedSeatRepository.java b/src/main/java/com/ceos23/cgv/domain/reservation/repository/ReservedSeatRepository.java new file mode 100644 index 00000000..6f840f1b --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/reservation/repository/ReservedSeatRepository.java @@ -0,0 +1,13 @@ +package com.ceos23.cgv.domain.reservation.repository; + +import com.ceos23.cgv.domain.reservation.entity.ReservedSeat; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface ReservedSeatRepository extends JpaRepository { + // 특정 상영 일정에 이미 예매된 좌석(회색 처리용) 목록 조회 + List findByScreeningId(Long screeningId); + + // 특정 예매 ID에 해당하는 모든 예매 좌석 삭제 + void deleteAllByReservationId(Long reservationId); +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/reservation/service/ReservationService.java b/src/main/java/com/ceos23/cgv/domain/reservation/service/ReservationService.java new file mode 100644 index 00000000..80053d2e --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/reservation/service/ReservationService.java @@ -0,0 +1,204 @@ +package com.ceos23.cgv.domain.reservation.service; + +import com.ceos23.cgv.domain.movie.entity.Screening; +import com.ceos23.cgv.domain.movie.repository.ScreeningRepository; +import com.ceos23.cgv.domain.payment.service.PaymentService; +import com.ceos23.cgv.domain.reservation.dto.ReservedSeatRequest; +import com.ceos23.cgv.domain.reservation.entity.Reservation; +import com.ceos23.cgv.domain.reservation.entity.ReservedSeat; +import com.ceos23.cgv.domain.reservation.enums.Payment; +import com.ceos23.cgv.domain.reservation.policy.ReservationPricePolicy; +import com.ceos23.cgv.domain.reservation.repository.ReservationRepository; +import com.ceos23.cgv.domain.reservation.repository.ReservedSeatRepository; +import com.ceos23.cgv.domain.user.entity.User; +import com.ceos23.cgv.domain.user.repository.UserRepository; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; +import java.util.UUID; + +@Service +@Slf4j +public class ReservationService { + + private final ReservationRepository reservationRepository; + private final UserRepository userRepository; + private final ScreeningRepository screeningRepository; + private final ReservedSeatRepository reservedSeatRepository; + private final PaymentService paymentService; + private final TransactionTemplate transactionTemplate; + + public ReservationService(ReservationRepository reservationRepository, + UserRepository userRepository, + ScreeningRepository screeningRepository, + ReservedSeatRepository reservedSeatRepository, + PaymentService paymentService, + PlatformTransactionManager transactionManager) { + this.reservationRepository = reservationRepository; + this.userRepository = userRepository; + this.screeningRepository = screeningRepository; + this.reservedSeatRepository = reservedSeatRepository; + this.paymentService = paymentService; + this.transactionTemplate = new TransactionTemplate(transactionManager); + } + + /** + * 영화 예매 로직 + */ + public Reservation createReservation(Long userId, Long screeningId, int peopleCount, Payment payment, + String couponCode, List seats) { + Reservation pendingReservation = transactionTemplate.execute(status -> + createPendingReservation(userId, screeningId, peopleCount, payment, couponCode, seats) + ); + + try { + paymentService.requestInstantPayment(pendingReservation); + } catch (CustomException e) { + cancelPendingReservation(pendingReservation.getPaymentId()); + throw e; + } catch (RuntimeException e) { + cancelPendingReservation(pendingReservation.getPaymentId()); + throw new CustomException(ErrorCode.PAYMENT_FAILED); + } + + try { + return transactionTemplate.execute(status -> completeReservation(pendingReservation.getPaymentId())); + } catch (RuntimeException e) { + compensatePaidReservation(pendingReservation.getPaymentId()); + throw e; + } + } + + private Reservation createPendingReservation(Long userId, Long screeningId, int peopleCount, Payment payment, + String couponCode, List seats) { + User user = findUser(userId); + Screening screening = findScreening(screeningId); + validateSeats(peopleCount, seats); + int calculatedPrice = ReservationPricePolicy.calculate(screening, peopleCount, couponCode); + + Reservation reservation = Reservation.create( + user, + screening, + peopleCount, + calculatedPrice, + payment, + couponCode, + createSaleNumber(), + paymentService.createPaymentId() + ); + + Reservation savedReservation = reservationRepository.save(reservation); + saveReservedSeats(savedReservation, screening, seats); + + return savedReservation; + } + + private Reservation completeReservation(String paymentId) { + Reservation reservation = findReservationByPaymentId(paymentId); + reservation.completePayment(); + return reservation; + } + + private void cancelPendingReservation(String paymentId) { + transactionTemplate.executeWithoutResult(status -> { + Reservation reservation = findReservationByPaymentId(paymentId); + reservedSeatRepository.deleteAllByReservationId(reservation.getId()); + reservation.cancel(); + }); + } + + private void compensatePaidReservation(String paymentId) { + try { + paymentService.cancelPayment(paymentId); + } catch (RuntimeException e) { + log.error("외부 결제 취소 보상 처리에 실패했습니다. paymentId={}", paymentId, e); + } + + cancelPendingReservation(paymentId); + } + + private User findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + } + + private Screening findScreening(Long screeningId) { + return screeningRepository.findByIdForUpdate(screeningId) + .orElseThrow(() -> new CustomException(ErrorCode.SCREENING_NOT_FOUND)); + } + + private Reservation findReservationByPaymentId(String paymentId) { + return reservationRepository.findByPaymentId(paymentId) + .orElseThrow(() -> new CustomException(ErrorCode.RESERVATION_NOT_FOUND)); + } + + private void validateSeats(int peopleCount, List seats) { + if (seats == null || seats.isEmpty() || seats.size() != peopleCount) { + throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + } + } + + private String createSaleNumber() { + return UUID.randomUUID().toString().substring(0, 15); + } + + private void saveReservedSeats(Reservation reservation, Screening screening, + List seats) { + List reservedSeats = seats.stream() + .map(seatInfo -> ReservedSeat.create( + reservation, + screening, + seatInfo.row(), + seatInfo.col() + )) + .toList(); + + try { + reservedSeatRepository.saveAll(reservedSeats); + } catch (DataIntegrityViolationException e) { + throw new CustomException(ErrorCode.SEAT_ALREADY_RESERVED); + } + } + + /** + * 영화 예매 취소 로직 + */ + public void cancelReservation(Long userId, Long reservationId) { + String paymentId = transactionTemplate.execute(status -> + getCancelablePaymentId(userId, reservationId) + ); + + paymentService.cancelPayment(paymentId); + + transactionTemplate.executeWithoutResult(status -> + cancelPaidReservation(userId, reservationId) + ); + } + + private String getCancelablePaymentId(Long userId, Long reservationId) { + Reservation reservation = reservationRepository.findById(reservationId) + .orElseThrow(() -> new CustomException(ErrorCode.RESERVATION_NOT_FOUND)); + + reservation.validateCancelableBy(userId); + reservation.validatePaymentCompleted(); + + return reservation.getPaymentId(); + } + + private void cancelPaidReservation(Long userId, Long reservationId) { + Reservation reservation = reservationRepository.findById(reservationId) + .orElseThrow(() -> new CustomException(ErrorCode.RESERVATION_NOT_FOUND)); + + reservation.validateCancelableBy(userId); + reservation.validatePaymentCompleted(); + reservedSeatRepository.deleteAllByReservationId(reservationId); + + reservation.cancel(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/reservation/service/ReservedSeatService.java b/src/main/java/com/ceos23/cgv/domain/reservation/service/ReservedSeatService.java new file mode 100644 index 00000000..ba644733 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/reservation/service/ReservedSeatService.java @@ -0,0 +1,67 @@ +package com.ceos23.cgv.domain.reservation.service; + +import com.ceos23.cgv.domain.movie.entity.Screening; +import com.ceos23.cgv.domain.movie.repository.ScreeningRepository; +import com.ceos23.cgv.domain.reservation.dto.ReservedSeatRequest; +import com.ceos23.cgv.domain.reservation.entity.Reservation; +import com.ceos23.cgv.domain.reservation.entity.ReservedSeat; +import com.ceos23.cgv.domain.reservation.repository.ReservationRepository; +import com.ceos23.cgv.domain.reservation.repository.ReservedSeatRepository; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReservedSeatService { + + private final ReservedSeatRepository reservedSeatRepository; + private final ReservationRepository reservationRepository; + private final ScreeningRepository screeningRepository; + + @Transactional + public List createReservedSeats(ReservedSeatRequest request) { + Reservation reservation = findReservation(request.reservationId()); + Screening screening = findScreening(request.screeningId()); + List reservedSeats = createReservedSeats(request, reservation, screening); + + try { + return reservedSeatRepository.saveAll(reservedSeats); + } catch (DataIntegrityViolationException e) { + throw new CustomException(ErrorCode.SEAT_ALREADY_RESERVED); + } + } + + private Reservation findReservation(Long reservationId) { + return reservationRepository.findById(reservationId) + .orElseThrow(() -> new CustomException(ErrorCode.RESERVATION_NOT_FOUND)); + } + + private Screening findScreening(Long screeningId) { + return screeningRepository.findByIdForUpdate(screeningId) + .orElseThrow(() -> new CustomException(ErrorCode.SCREENING_NOT_FOUND)); + } + + private List createReservedSeats(ReservedSeatRequest request, + Reservation reservation, + Screening screening) { + return request.seats().stream() + .map(seatInfo -> ReservedSeat.create( + reservation, + screening, + seatInfo.row(), + seatInfo.col() + )) + .toList(); + } + + public List getReservedSeatsByScreeningId(Long screeningId) { + return reservedSeatRepository.findByScreeningId(screeningId); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/user/controller/CinetalkController.java b/src/main/java/com/ceos23/cgv/domain/user/controller/CinetalkController.java new file mode 100644 index 00000000..0f2ccf60 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/user/controller/CinetalkController.java @@ -0,0 +1,64 @@ +package com.ceos23.cgv.domain.user.controller; + +import com.ceos23.cgv.domain.user.dto.CinetalkCreateRequest; +import com.ceos23.cgv.domain.user.dto.CinetalkResponse; +import com.ceos23.cgv.domain.user.entity.Cinetalk; +import com.ceos23.cgv.domain.user.service.CinetalkService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/cinetalks") +@RequiredArgsConstructor +@Tag(name = "Cinetalk API", description = "씨네톡(커뮤니티) 게시글 작성 및 조회 API") +public class CinetalkController { + + private final CinetalkService cinetalkService; + + @PostMapping + @Operation(summary = "씨네톡 게시글 작성", description = "영화나 극장을 선택(혹은 생략)하여 자유게시글을 작성합니다.") + public ResponseEntity createCinetalk(@RequestBody CinetalkCreateRequest request) { + Cinetalk cinetalk = cinetalkService.createCinetalk( + request.userId(), + request.content(), + request.movieId(), + request.cinemaId() + ); + return ResponseEntity.status(HttpStatus.CREATED) + .body(CinetalkResponse.from(cinetalk)); + } + + @GetMapping + @Operation(summary = "씨네톡 전체 목록 조회", description = "작성된 모든 씨네톡 게시글을 조회합니다.") + public ResponseEntity> getAllCinetalks() { + List responses = cinetalkService.getAllCinetalks().stream() + .map(CinetalkResponse::from) + .collect(Collectors.toList()); + return ResponseEntity.ok(responses); + } + + @GetMapping("/movie/{movieId}") + @Operation(summary = "특정 영화의 씨네톡 조회") + public ResponseEntity> getCinetalksByMovie(@PathVariable Long movieId) { + List responses = cinetalkService.getCinetalksByMovieId(movieId).stream() + .map(CinetalkResponse::from) + .collect(Collectors.toList()); + return ResponseEntity.ok(responses); + } + + @GetMapping("/cinema/{cinemaId}") + @Operation(summary = "특정 극장의 씨네톡 조회", description = "특정 극장에 작성된 씨네톡 게시글 목록을 조회합니다.") + public ResponseEntity> getCinetalksByCinema(@PathVariable Long cinemaId) { + List responses = cinetalkService.getCinetalksByCinemaId(cinemaId).stream() + .map(CinetalkResponse::from) + .collect(Collectors.toList()); + return ResponseEntity.ok(responses); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/user/dto/CinetalkCreateRequest.java b/src/main/java/com/ceos23/cgv/domain/user/dto/CinetalkCreateRequest.java new file mode 100644 index 00000000..45103610 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/user/dto/CinetalkCreateRequest.java @@ -0,0 +1,9 @@ +package com.ceos23.cgv.domain.user.dto; + +public record CinetalkCreateRequest( + Long userId, + Long movieId, + Long cinemaId, + String content +) { +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/user/dto/CinetalkResponse.java b/src/main/java/com/ceos23/cgv/domain/user/dto/CinetalkResponse.java new file mode 100644 index 00000000..65b33446 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/user/dto/CinetalkResponse.java @@ -0,0 +1,22 @@ +package com.ceos23.cgv.domain.user.dto; + +import com.ceos23.cgv.domain.user.entity.Cinetalk; +import java.time.LocalDateTime; + +public record CinetalkResponse( + Long cinetalkId, + String authorName, + String content, + int likeCount, + LocalDateTime createdAt +) { + public static CinetalkResponse from(Cinetalk cinetalk) { + return new CinetalkResponse( + cinetalk.getId(), + cinetalk.getUser().getNickname(), + cinetalk.getContent(), + cinetalk.getLikeCount(), + cinetalk.getCreatedAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/user/entity/Cinetalk.java b/src/main/java/com/ceos23/cgv/domain/user/entity/Cinetalk.java new file mode 100644 index 00000000..feea08de --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/user/entity/Cinetalk.java @@ -0,0 +1,49 @@ +package com.ceos23.cgv.domain.user.entity; + +import com.ceos23.cgv.domain.cinema.entity.Cinema; +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.global.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "cinetalks") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Cinetalk extends BaseTimeEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "cinetalk_id") + private Long id; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Builder.Default + @Column(name = "like_count", nullable = false) + private int likeCount = 0; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "movie_id") + private Movie movie; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cinema_id") + private Cinema cinema; + + public static Cinetalk create(User user, String content, Movie movie, Cinema cinema) { + return Cinetalk.builder() + .user(user) + .content(content) + .likeCount(0) + .movie(movie) + .cinema(cinema) + .build(); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/user/entity/User.java b/src/main/java/com/ceos23/cgv/domain/user/entity/User.java new file mode 100644 index 00000000..a53b3e1f --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/user/entity/User.java @@ -0,0 +1,78 @@ +package com.ceos23.cgv.domain.user.entity; + +import com.ceos23.cgv.domain.user.enums.Badge; +import com.ceos23.cgv.domain.user.enums.Role; +import com.ceos23.cgv.global.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class User extends BaseTimeEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long id; + + @Column(nullable = false, length = 50) + private String name; + + @Column(nullable = false, unique = true, length = 100) + private String email; + + @Column(nullable = false, unique = true, length = 50) + private String nickname; + + @Column(columnDefinition = "TEXT") + private String description; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @Builder.Default + private Badge badge = Badge.NORMAL; + + private String imageUrl; + + @Column(nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @Builder.Default + private Role role = Role.ROLE_USER; + + @Column(length = 500) + private String refreshToken; + + public static User create(String name, String email, String nickname, String encodedPassword) { + return User.builder() + .name(name) + .email(email) + .nickname(nickname) + .password(encodedPassword) + .role(Role.ROLE_USER) + .build(); + } + + public static User createWithoutPassword(String name, String email, String nickname) { + return User.builder() + .name(name) + .email(email) + .nickname(nickname) + .role(Role.ROLE_USER) + .build(); + } + + // 토큰 업데이트 및 삭제용 메서드 + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public void destroyRefreshToken() { + this.refreshToken = null; + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/user/entity/UserStatic.java b/src/main/java/com/ceos23/cgv/domain/user/entity/UserStatic.java new file mode 100644 index 00000000..b30054b5 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/user/entity/UserStatic.java @@ -0,0 +1,34 @@ +package com.ceos23.cgv.domain.user.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "user_statics") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class UserStatic { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_static_id") + private Long id; + + @Column(nullable = false) + private int cinetalkCount; + + @Column(nullable = false) + private int followingCount; + + @Column(nullable = false) + private int followerCount; + + @Column(nullable = false) + private int badgeCount; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + +} diff --git a/src/main/java/com/ceos23/cgv/domain/user/enums/Badge.java b/src/main/java/com/ceos23/cgv/domain/user/enums/Badge.java new file mode 100644 index 00000000..e32d013c --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/user/enums/Badge.java @@ -0,0 +1,16 @@ +package com.ceos23.cgv.domain.user.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Badge { + NORMAL("일반"), + VIP("VIP"), + RVIP("RVIP"), + VVIP("VVIP"), + SVIP("SVIP"); + + private final String description; +} diff --git a/src/main/java/com/ceos23/cgv/domain/user/enums/Role.java b/src/main/java/com/ceos23/cgv/domain/user/enums/Role.java new file mode 100644 index 00000000..2fc79fbf --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/user/enums/Role.java @@ -0,0 +1,13 @@ +package com.ceos23.cgv.domain.user.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Role { + ROLE_USER("일반 사용자"), + ROLE_ADMIN("관리자"); + + private final String description; +} diff --git a/src/main/java/com/ceos23/cgv/domain/user/repository/CinetalkRepository.java b/src/main/java/com/ceos23/cgv/domain/user/repository/CinetalkRepository.java new file mode 100644 index 00000000..e5cc08ad --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/user/repository/CinetalkRepository.java @@ -0,0 +1,15 @@ +package com.ceos23.cgv.domain.user.repository; + +import com.ceos23.cgv.domain.user.entity.Cinetalk; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CinetalkRepository extends JpaRepository { + + // 영화 ID로 씨네톡 목록 찾기 + List findByMovieId(Long movieId); + + // 극장 ID로 씨네톡 목록 찾기 + List findByCinemaId(Long cinemaId); +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/user/repository/UserRepository.java b/src/main/java/com/ceos23/cgv/domain/user/repository/UserRepository.java new file mode 100644 index 00000000..d3c9b5bf --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/user/repository/UserRepository.java @@ -0,0 +1,14 @@ +package com.ceos23.cgv.domain.user.repository; + +import com.ceos23.cgv.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + Optional findByEmail(String email); + + boolean existsByEmail(String email); + + boolean existsByNickname(String nickname); +} diff --git a/src/main/java/com/ceos23/cgv/domain/user/repository/UserStaticRepository.java b/src/main/java/com/ceos23/cgv/domain/user/repository/UserStaticRepository.java new file mode 100644 index 00000000..5f48c754 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/user/repository/UserStaticRepository.java @@ -0,0 +1,7 @@ +package com.ceos23.cgv.domain.user.repository; + +import com.ceos23.cgv.domain.user.entity.UserStatic; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserStaticRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/domain/user/service/CinetalkService.java b/src/main/java/com/ceos23/cgv/domain/user/service/CinetalkService.java new file mode 100644 index 00000000..1904c711 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/user/service/CinetalkService.java @@ -0,0 +1,83 @@ +package com.ceos23.cgv.domain.user.service; + +import com.ceos23.cgv.domain.cinema.entity.Cinema; +import com.ceos23.cgv.domain.cinema.repository.CinemaRepository; +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.domain.movie.repository.MovieRepository; +import com.ceos23.cgv.domain.user.entity.Cinetalk; +import com.ceos23.cgv.domain.user.entity.User; +import com.ceos23.cgv.domain.user.repository.CinetalkRepository; +import com.ceos23.cgv.domain.user.repository.UserRepository; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CinetalkService { + + private final CinetalkRepository cinetalkRepository; + private final UserRepository userRepository; + private final MovieRepository movieRepository; + private final CinemaRepository cinemaRepository; + + /** + * [POST] 씨네톡 게시글 작성 + */ + @Transactional + public Cinetalk createCinetalk(Long userId, String content, Long movieId, Long cinemaId) { + User user = findUser(userId); + Movie movie = findMovieOrNull(movieId); + Cinema cinema = findCinemaOrNull(cinemaId); + Cinetalk cinetalk = Cinetalk.create(user, content, movie, cinema); + + return cinetalkRepository.save(cinetalk); + } + + private User findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + } + + private Movie findMovieOrNull(Long movieId) { + if (movieId == null) { + return null; + } + + return movieRepository.findById(movieId).orElse(null); + } + + private Cinema findCinemaOrNull(Long cinemaId) { + if (cinemaId == null) { + return null; + } + + return cinemaRepository.findById(cinemaId).orElse(null); + } + + /** + * [GET] 전체 씨네톡 게시글 조회 + */ + public List getAllCinetalks() { + return cinetalkRepository.findAll(); + } + + /** + * [GET] 특정 영화의 씨네톡 목록 조회 + */ + public List getCinetalksByMovieId(Long movieId) { + return cinetalkRepository.findByMovieId(movieId); + } + + /** + * [GET] 특정 극장의 씨네톡 목록 조회 + */ + public List getCinetalksByCinemaId(Long cinemaId) { + return cinetalkRepository.findByCinemaId(cinemaId); + } +} diff --git a/src/main/java/com/ceos23/cgv/domain/user/service/UserService.java b/src/main/java/com/ceos23/cgv/domain/user/service/UserService.java new file mode 100644 index 00000000..ef9db18d --- /dev/null +++ b/src/main/java/com/ceos23/cgv/domain/user/service/UserService.java @@ -0,0 +1,43 @@ +package com.ceos23.cgv.domain.user.service; + +import com.ceos23.cgv.domain.user.entity.User; +import com.ceos23.cgv.domain.user.repository.UserRepository; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + + /** + * 회원 가입 + */ + @Transactional + public User join(String name, String email, String nickname) { + validateNickname(nickname); + + User newUser = User.createWithoutPassword(name, email, nickname); + + return userRepository.save(newUser); + } + + private void validateNickname(String nickname) { + if (userRepository.existsByNickname(nickname)) { + throw new CustomException(ErrorCode.NICKNAME_ALREADY_EXISTS); + } + } + + /** + * 유저 단건 조회 + */ + public User getUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/ceos23/cgv/global/cache/CacheConfig.java b/src/main/java/com/ceos23/cgv/global/cache/CacheConfig.java new file mode 100644 index 00000000..0f497385 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/global/cache/CacheConfig.java @@ -0,0 +1,32 @@ +package com.ceos23.cgv.global.cache; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager( + CacheNames.MOVIES, + CacheNames.MOVIE_DETAILS, + CacheNames.CINEMAS, + CacheNames.CINEMA_DETAILS, + CacheNames.CONCESSION_PRODUCTS, + CacheNames.EVENTS + ); + cacheManager.setCaffeine(Caffeine.newBuilder() + .maximumSize(500) + .expireAfterWrite(Duration.ofMinutes(10)) + .recordStats()); + return cacheManager; + } +} diff --git a/src/main/java/com/ceos23/cgv/global/cache/CacheNames.java b/src/main/java/com/ceos23/cgv/global/cache/CacheNames.java new file mode 100644 index 00000000..8bb04801 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/global/cache/CacheNames.java @@ -0,0 +1,14 @@ +package com.ceos23.cgv.global.cache; + +public final class CacheNames { + + public static final String MOVIES = "movies"; + public static final String MOVIE_DETAILS = "movieDetails"; + public static final String CINEMAS = "cinemas"; + public static final String CINEMA_DETAILS = "cinemaDetails"; + public static final String CONCESSION_PRODUCTS = "concessionProducts"; + public static final String EVENTS = "events"; + + private CacheNames() { + } +} diff --git a/src/main/java/com/ceos23/cgv/global/common/dto/ApiResponse.java b/src/main/java/com/ceos23/cgv/global/common/dto/ApiResponse.java new file mode 100644 index 00000000..d6bb4251 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/global/common/dto/ApiResponse.java @@ -0,0 +1,30 @@ +package com.ceos23.cgv.global.common.dto; + +import org.springframework.http.HttpStatus; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +public record ApiResponse( + int status, + String message, + T data +) { + // 1. 단일 데이터 응답용 + public static ApiResponse success(T data) { + return new ApiResponse<>(HttpStatus.OK.value(), "요청에 성공하였습니다.", data); + } + + // 2. Entity 리스트를 DTO 리스트로 변환하여 응답하는 헬퍼 메서드! + public static ApiResponse> success(List entities, Function mapper) { + List dtoList = entities.stream() + .map(mapper) + .collect(Collectors.toList()); + return new ApiResponse<>(HttpStatus.OK.value(), "요청에 성공하였습니다.", dtoList); + } + + // 3. POST 생성 성공용 + public static ApiResponse created(T data) { + return new ApiResponse<>(HttpStatus.CREATED.value(), "생성이 완료되었습니다.", data); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/global/config/SwaggerConfig.java b/src/main/java/com/ceos23/cgv/global/config/SwaggerConfig.java new file mode 100644 index 00000000..551ea4dd --- /dev/null +++ b/src/main/java/com/ceos23/cgv/global/config/SwaggerConfig.java @@ -0,0 +1,29 @@ +package com.ceos23.cgv.global.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + private static final String SECURITY_SCHEME_NAME = "bearerAuth"; + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME_NAME)) + .components(new Components() + .addSecuritySchemes(SECURITY_SCHEME_NAME, + new SecurityScheme() + .name(SECURITY_SCHEME_NAME) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + ) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/global/entity/BaseTimeEntity.java b/src/main/java/com/ceos23/cgv/global/entity/BaseTimeEntity.java new file mode 100644 index 00000000..3ffa99e7 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/global/entity/BaseTimeEntity.java @@ -0,0 +1,24 @@ +package com.ceos23.cgv.global.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/ceos23/cgv/global/exception/CustomException.java b/src/main/java/com/ceos23/cgv/global/exception/CustomException.java new file mode 100644 index 00000000..feb62329 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/global/exception/CustomException.java @@ -0,0 +1,10 @@ +package com.ceos23.cgv.global.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class CustomException extends RuntimeException { + private final ErrorCode errorCode; +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/global/exception/ErrorCode.java b/src/main/java/com/ceos23/cgv/global/exception/ErrorCode.java new file mode 100644 index 00000000..062725ac --- /dev/null +++ b/src/main/java/com/ceos23/cgv/global/exception/ErrorCode.java @@ -0,0 +1,67 @@ +package com.ceos23.cgv.global.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + + // Common (공통 에러) + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "잘못된 입력값입니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "C002", "허용되지 않은 HTTP 메서드입니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C003", "서버 내부 에러가 발생했습니다."), + AUTHENTICATION_REQUIRED(HttpStatus.UNAUTHORIZED, "C004", "인증이 필요합니다."), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "C005", "접근 권한이 없습니다."), + + // Domain: User & Cinetalk + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "존재하지 않는 유저입니다."), + EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "U002", "이미 가입되어 있는 이메일입니다."), + NICKNAME_ALREADY_EXISTS(HttpStatus.CONFLICT, "U003", "이미 사용 중인 닉네임입니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "U004", "유효하지 않거나 만료된 Refresh Token 입니다."), + REFRESH_TOKEN_MISMATCH(HttpStatus.UNAUTHORIZED, "U005", "토큰 정보가 일치하지 않습니다. (탈취 의심)"), + + // Domain: Movie & Screening + MOVIE_NOT_FOUND(HttpStatus.NOT_FOUND, "M001", "존재하지 않는 영화입니다."), + SCREENING_NOT_FOUND(HttpStatus.NOT_FOUND, "M002", "상영 일정을 찾을 수 없습니다."), + + // Domain: Cinema & Theater + CINEMA_NOT_FOUND(HttpStatus.NOT_FOUND, "T001", "존재하지 않는 극장 지점입니다."), + THEATER_NOT_FOUND(HttpStatus.NOT_FOUND, "T002", "존재하지 않는 상영관입니다."), + + // Domain: Reservation + RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "R001", "예매 정보를 찾을 수 없습니다."), + SEAT_ALREADY_RESERVED(HttpStatus.CONFLICT, "R002", "이미 예매가 완료된 좌석입니다. 다른 좌석을 선택해 주세요."), + RESERVATION_ACCESS_DENIED(HttpStatus.FORBIDDEN, "R003", "본인의 예매 내역만 취소할 수 있습니다."), + RESERVATION_ALREADY_CANCELED(HttpStatus.CONFLICT, "R004", "이미 취소 처리된 예매입니다."), + INVALID_COUPON_CODE(HttpStatus.BAD_REQUEST, "R005", "유효하지 않은 쿠폰 코드입니다."), + PAYMENT_FAILED(HttpStatus.BAD_GATEWAY, "R006", "결제 처리에 실패했습니다."), + PAYMENT_CANCEL_FAILED(HttpStatus.BAD_GATEWAY, "R007", "결제 취소에 실패했습니다."), + PAYMENT_NOT_COMPLETED(HttpStatus.CONFLICT, "R008", "결제 완료 상태의 예매만 취소할 수 있습니다."), + PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "R009", "결제 정보를 찾을 수 없습니다."), + + // Domain: Concession & Inventory + PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "S001", "존재하지 않는 매점 상품입니다."), + INVENTORY_SHORTAGE(HttpStatus.BAD_REQUEST, "S002", "상품의 재고가 부족합니다."), + INVALID_STOCK_QUANTITY(HttpStatus.BAD_REQUEST, "S003", "재고는 최소 1개 이상이어야 합니다."), + FOOD_ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "S004", "매점 주문 정보를 찾을 수 없습니다."), + FOOD_ORDER_NOT_PENDING(HttpStatus.CONFLICT, "S005", "결제 대기 상태의 매점 주문만 완료할 수 있습니다."), + + // Domain: Person, Event, Photo + PERSON_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "인물을 찾을 수 없습니다."), + EVENT_NOT_FOUND(HttpStatus.NOT_FOUND, "E001", "이벤트를 찾을 수 없습니다."), + PHOTO_NOT_FOUND(HttpStatus.NOT_FOUND, "PH001", "사진을 찾을 수 없습니다."), + PHOTO_TARGET_REQUIRED(HttpStatus.BAD_REQUEST, "PH002", "사진은 최소한 영화(movieId) 또는 인물(personId) 중 하나 이상의 대상에 등록되어야 합니다."), + + // Domain: Region + UNSUPPORTED_REGION(HttpStatus.BAD_REQUEST, "RG001", "지원하지 않는 지역 이름입니다."); + + private final HttpStatus status; + private final String code; + private final String message; + + public String getDescription() { + return message; + } +} diff --git a/src/main/java/com/ceos23/cgv/global/exception/ErrorResponse.java b/src/main/java/com/ceos23/cgv/global/exception/ErrorResponse.java new file mode 100644 index 00000000..af0ad070 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/global/exception/ErrorResponse.java @@ -0,0 +1,45 @@ +package com.ceos23.cgv.global.exception; + +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import java.util.List; +import java.util.stream.Collectors; + +public record ErrorResponse( + int status, + String code, + String message, + List errors +) { + // 1. 일반적인 CustomException 응답용 + public static ErrorResponse from(ErrorCode errorCode) { + return new ErrorResponse( + errorCode.getStatus().value(), + errorCode.getCode(), + errorCode.getMessage(), + List.of() // 에러 리스트는 빈 배열로 반환 + ); + } + + // 2. @Valid 유효성 검사 실패(@RequestBody) 응답용 + public static ErrorResponse of(ErrorCode errorCode, BindingResult bindingResult) { + return new ErrorResponse( + errorCode.getStatus().value(), + errorCode.getCode(), + errorCode.getMessage(), + ValidationError.of(bindingResult) + ); + } + + // Validation 상세 에러를 담기 위한 내부 record + public record ValidationError(String field, String value, String reason) { + private static List of(final BindingResult bindingResult) { + return bindingResult.getFieldErrors().stream() + .map(error -> new ValidationError( + error.getField(), + error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(), + error.getDefaultMessage())) + .collect(Collectors.toList()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/global/exception/GlobalExceptionHandler.java b/src/main/java/com/ceos23/cgv/global/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..8777b68c --- /dev/null +++ b/src/main/java/com/ceos23/cgv/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,97 @@ +package com.ceos23.cgv.global.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice // 모든 Controller에서 발생하는 예외를 전역적으로 캐치합니다. +public class GlobalExceptionHandler { + + /** + * [Exception] 비즈니스 로직에서 발생하는 커스텀 예외 + */ + @ExceptionHandler(CustomException.class) + protected ResponseEntity handleCustomException(CustomException e) { + log.error("handleCustomException: {}", e.getErrorCode().getMessage()); + return ResponseEntity + .status(e.getErrorCode().getStatus()) + .body(ErrorResponse.from(e.getErrorCode())); + } + + /** + * [Exception] @Valid 검증 실패 시 발생 (예: DTO 조건 위반) + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + log.error("handleMethodArgumentNotValidException", e); + return ResponseEntity + .status(ErrorCode.INVALID_INPUT_VALUE.getStatus()) + .body(ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult())); + } + + /** + * [Exception] 지원하지 않는 HTTP 메서드 호출 시 발생 (예: POST인데 GET으로 호출) + */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + protected ResponseEntity handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { + log.error("handleHttpRequestMethodNotSupportedException", e); + return ResponseEntity + .status(ErrorCode.METHOD_NOT_ALLOWED.getStatus()) + .body(ErrorResponse.from(ErrorCode.METHOD_NOT_ALLOWED)); + } + + /** + * [Exception] 요청 본문 역직렬화 중 발생한 예외 처리 + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + protected ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + log.error("handleHttpMessageNotReadableException", e); + + CustomException customException = findCustomException(e); + if (customException != null) { + return handleCustomException(customException); + } + + return ResponseEntity + .status(ErrorCode.INVALID_INPUT_VALUE.getStatus()) + .body(ErrorResponse.from(ErrorCode.INVALID_INPUT_VALUE)); + } + + /** + * [Exception] 외부 변환/프레임워크에서 발생하는 기본 IllegalArgumentException 처리 + */ + @ExceptionHandler(IllegalArgumentException.class) + protected ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { + log.error("handleIllegalArgumentException", e); + return ResponseEntity + .status(ErrorCode.INVALID_INPUT_VALUE.getStatus()) + .body(ErrorResponse.from(ErrorCode.INVALID_INPUT_VALUE)); + } + + /** + * [Exception] 그 외 서버에서 발생하는 모든 예상치 못한 에러 (NullPointerException 등 500 에러) + */ + @ExceptionHandler(Exception.class) + protected ResponseEntity handleException(Exception e) { + log.error("handleException", e); + return ResponseEntity + .status(ErrorCode.INTERNAL_SERVER_ERROR.getStatus()) + .body(ErrorResponse.from(ErrorCode.INTERNAL_SERVER_ERROR)); + } + + private CustomException findCustomException(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof CustomException customException) { + return customException; + } + current = current.getCause(); + } + return null; + } +} diff --git a/src/main/java/com/ceos23/cgv/global/logging/RequestLoggingFilter.java b/src/main/java/com/ceos23/cgv/global/logging/RequestLoggingFilter.java new file mode 100644 index 00000000..3a2de3d0 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/global/logging/RequestLoggingFilter.java @@ -0,0 +1,54 @@ +package com.ceos23.cgv.global.logging; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +@Slf4j +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class RequestLoggingFilter extends OncePerRequestFilter { + + private static final String REQUEST_ID = "requestId"; + private static final String REQUEST_ID_HEADER = "X-Request-Id"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String requestId = resolveRequestId(request); + long startTime = System.currentTimeMillis(); + MDC.put(REQUEST_ID, requestId); + response.setHeader(REQUEST_ID_HEADER, requestId); + + try { + filterChain.doFilter(request, response); + } finally { + long durationMs = System.currentTimeMillis() - startTime; + log.info("http_request method={} uri={} status={} durationMs={} requestId={}", + request.getMethod(), + request.getRequestURI(), + response.getStatus(), + durationMs, + requestId); + MDC.remove(REQUEST_ID); + } + } + + private String resolveRequestId(HttpServletRequest request) { + String requestId = request.getHeader(REQUEST_ID_HEADER); + if (requestId == null || requestId.isBlank()) { + return UUID.randomUUID().toString(); + } + return requestId; + } +} diff --git a/src/main/java/com/ceos23/cgv/global/security/CustomAccessDeniedHandler.java b/src/main/java/com/ceos23/cgv/global/security/CustomAccessDeniedHandler.java new file mode 100644 index 00000000..976e2560 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/global/security/CustomAccessDeniedHandler.java @@ -0,0 +1,33 @@ +package com.ceos23.cgv.global.security; + +import com.ceos23.cgv.global.exception.ErrorCode; +import com.ceos23.cgv.global.exception.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + writeErrorResponse(response, ErrorCode.ACCESS_DENIED); + } + + private void writeErrorResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException { + response.setStatus(errorCode.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + objectMapper.writeValue(response.getWriter(), ErrorResponse.from(errorCode)); + } +} diff --git a/src/main/java/com/ceos23/cgv/global/security/CustomAuthenticationEntryPoint.java b/src/main/java/com/ceos23/cgv/global/security/CustomAuthenticationEntryPoint.java new file mode 100644 index 00000000..b3e8f266 --- /dev/null +++ b/src/main/java/com/ceos23/cgv/global/security/CustomAuthenticationEntryPoint.java @@ -0,0 +1,33 @@ +package com.ceos23.cgv.global.security; + +import com.ceos23.cgv.global.exception.ErrorCode; +import com.ceos23.cgv.global.exception.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + writeErrorResponse(response, ErrorCode.AUTHENTICATION_REQUIRED); + } + + private void writeErrorResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException { + response.setStatus(errorCode.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + objectMapper.writeValue(response.getWriter(), ErrorResponse.from(errorCode)); + } +} diff --git a/src/main/java/com/ceos23/cgv/global/security/CustomUserDetailsService.java b/src/main/java/com/ceos23/cgv/global/security/CustomUserDetailsService.java new file mode 100644 index 00000000..231dbd7a --- /dev/null +++ b/src/main/java/com/ceos23/cgv/global/security/CustomUserDetailsService.java @@ -0,0 +1,39 @@ +package com.ceos23.cgv.global.security; + +import com.ceos23.cgv.domain.user.entity.User; +import com.ceos23.cgv.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.Collections; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + // 1. 로그인 시 입력받은 이메일로 DB에서 유저를 찾습니다. + return userRepository.findByEmail(email) + .map(this::createUserDetails) + .orElseThrow(() -> new UsernameNotFoundException("해당 이메일을 찾을 수 없습니다: " + email)); + } + + // 2. DB에 유저가 존재한다면, Spring Security가 이해할 수 있는 UserDetails 객체로 변환하여 반환합니다. + private UserDetails createUserDetails(User user) { + GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(user.getRole().name()); + + return new org.springframework.security.core.userdetails.User( + String.valueOf(user.getId()), + user.getPassword(), + Collections.singleton(grantedAuthority) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ceos23/cgv/global/security/JwtAuthenticationFilter.java b/src/main/java/com/ceos23/cgv/global/security/JwtAuthenticationFilter.java new file mode 100644 index 00000000..d238f29a --- /dev/null +++ b/src/main/java/com/ceos23/cgv/global/security/JwtAuthenticationFilter.java @@ -0,0 +1,39 @@ +package com.ceos23.cgv.global.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final TokenProvider tokenProvider; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + // 1. 헤더에서 토큰 추출 + String jwt = tokenProvider.getAccessToken(request); + + // 2. 토큰 유효성 검증 후 SecurityContext에 인증 정보 저장 + if (StringUtils.hasText(jwt) && tokenProvider.validateAccessToken(jwt)) { + Authentication authentication = tokenProvider.getAuthentication(jwt); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + // 3. 다음 필터로 진행 + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/ceos23/cgv/global/security/SecurityConfig.java b/src/main/java/com/ceos23/cgv/global/security/SecurityConfig.java new file mode 100644 index 00000000..06ee532f --- /dev/null +++ b/src/main/java/com/ceos23/cgv/global/security/SecurityConfig.java @@ -0,0 +1,79 @@ +package com.ceos23.cgv.global.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + // 1. CSRF 비활성화 (JWT 사용 시 일반적으로 비활성화) + .csrf(AbstractHttpConfigurer::disable) + + // 2. 세션 관리를 Stateless로 설정 (서버가 세션을 저장하지 않음) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + + .exceptionHandling(exception -> exception + .authenticationEntryPoint(customAuthenticationEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler) + ) + + // 3. 권한별 URL 접근 제어 + .authorizeHttpRequests(auth -> auth + // 로그인, 회원가입 API는 누구나 접근 가능 + .requestMatchers("/api/auth/**").permitAll() + + // Swagger UI 접근 허용 + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + + // 🚀 핵심: 관리자 전용 경로는 ADMIN 권한이 있어야만 접근 가능 + // Security 내부적으로 "ROLE_" 접두사를 붙여서 검사하므로 "ADMIN"이라고 적습니다. + .requestMatchers("/api/admin/**").hasRole("ADMIN") + + // 영화, 상영관 단순 조회 API는 비로그인 유저도 접근 가능하게 열어두는 예시 + .requestMatchers(HttpMethod.GET, "/api/v1/movies/**", "/api/v1/cinemas/**").permitAll() + + // 그 외 모든 요청(예매, 결제 등)은 로그인(인증)된 유저만 접근 가능 + .anyRequest().authenticated() + ) + + // 4. 커스텀 JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 등록 + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + // 비밀번호 암호화를 위한 Bean 등록 + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager( + AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } +} diff --git a/src/main/java/com/ceos23/cgv/global/security/TokenProvider.java b/src/main/java/com/ceos23/cgv/global/security/TokenProvider.java new file mode 100644 index 00000000..61167c1c --- /dev/null +++ b/src/main/java/com/ceos23/cgv/global/security/TokenProvider.java @@ -0,0 +1,142 @@ +package com.ceos23.cgv.global.security; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import jakarta.servlet.http.HttpServletRequest; +import java.security.Key; +import java.util.Collection; +import java.util.Date; +import java.util.stream.Collectors; + +@Component +@Slf4j +public class TokenProvider implements InitializingBean { + + private static final String BEARER_PREFIX = "Bearer "; + private static final long REFRESH_TOKEN_VALIDITY_MULTIPLIER = 24L * 14L; + + private final String secret; + private final long tokenValidityInMilliseconds; + private final UserDetailsService userDetailsService; + private Key key; + + public TokenProvider( + @Value("${jwt.secret}") String secret, + @Value("${jwt.token-validity-in-seconds:3600}") long tokenValidityInSeconds, + UserDetailsService userDetailsService) { + this.secret = secret; + this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000; + this.userDetailsService = userDetailsService; + } + + @Override + public void afterPropertiesSet() { + byte[] keyBytes = Decoders.BASE64.decode(secret); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + // 1. HttpServletRequest에서 토큰 추출 + public String getAccessToken(HttpServletRequest request) { + String bearerToken = request.getHeader(HttpHeaders.AUTHORIZATION); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(BEARER_PREFIX.length()); + } + return null; + } + + // 2. 토큰 생성 + public String createAccessToken(Long id, Authentication authentication) { + String authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + long now = (new Date()).getTime(); + Date validity = new Date(now + this.tokenValidityInMilliseconds); + + return Jwts.builder() + .setSubject(String.valueOf(id)) // Payload: 유저 식별자 (ID) + .claim("auth", authorities) // Payload: 권한 (ROLE_USER, ROLE_ADMIN 등) + .signWith(key, SignatureAlgorithm.HS512) + .setExpiration(validity) + .compact(); + } + + // 3. 토큰에서 유저 ID(Subject) 추출 + public String getTokenUserId(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + return claims.getSubject(); + } + + // 4. Authentication 객체 생성 + public Authentication getAuthentication(String token) { + // 1. 토큰 복호화 (Claims 추출) + Claims claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + + // 2. 권한 정보 추출 ("ROLE_USER" 등) + Collection authorities = + java.util.Arrays.stream(claims.get("auth").toString().split(",")) + .map(org.springframework.security.core.authority.SimpleGrantedAuthority::new) + .collect(java.util.stream.Collectors.toList()); + + // 3. UserDetails 객체 생성 (DB 조회 없이 토큰에 있는 유저 ID와 권한 정보만으로 생성!) + UserDetails principal = new org.springframework.security.core.userdetails.User( + claims.getSubject(), + "", + authorities + ); + + return new UsernamePasswordAuthenticationToken(principal, null, authorities); + } + + // 5. 토큰 유효성 검증 + public boolean validateAccessToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + log.warn("Invalid JWT signature. type={}, message={}", e.getClass().getSimpleName(), e.getMessage()); + } catch (ExpiredJwtException e) { + log.warn("Expired JWT. type={}, message={}", e.getClass().getSimpleName(), e.getMessage()); + } catch (UnsupportedJwtException | IllegalArgumentException e) { + log.warn("Invalid JWT. type={}, message={}", e.getClass().getSimpleName(), e.getMessage()); + } + return false; + } + + // 리프레쉬 토큰 생성 메서드 + public String createRefreshToken(Long id) { + long now = (new Date()).getTime(); + Date validity = new Date(now + this.tokenValidityInMilliseconds * REFRESH_TOKEN_VALIDITY_MULTIPLIER); + + return Jwts.builder() + .setSubject(String.valueOf(id)) + .signWith(key, SignatureAlgorithm.HS512) + .setExpiration(validity) + .compact(); + } + + public long getRefreshTokenValidityInSeconds() { + return tokenValidityInMilliseconds / 1000 * REFRESH_TOKEN_VALIDITY_MULTIPLIER; + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 00000000..b187eee2 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,33 @@ +server: + port: ${PORT:8080} + +spring: + application: + name: spring-boot + + datasource: + url: jdbc:mysql://${DB_HOST}:${DB_PORT:3306}/${DB_NAME}?sslMode=REQUIRED&allowPublicKeyRetrieval=true&useSSL=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: create + show-sql: true + properties: + hibernate: + format_sql: true + + logging: + level: + org.hibernate.SQL: debug + +jwt: + secret: ${JWT_SECRET} + token-validity-in-seconds: 3600 + +payment: + base-url: https://ceos.diggindie.com + store-id: whc9999 + api-secret-key: ${PAYMENT_API_SECRET_KEY:} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..ca735f1d --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,29 @@ + + + + + + + ${LOG_PATTERN} + + + + + logs/application.log + + ${LOG_PATTERN} + + + logs/application-%d{yyyy-MM-dd}.%i.log + 10MB + 14 + 300MB + + + + + + + + diff --git a/src/test/java/com/ceos23/cgv/ApplicationTests.java b/src/test/java/com/ceos23/cgv/ApplicationTests.java new file mode 100644 index 00000000..28975fd7 --- /dev/null +++ b/src/test/java/com/ceos23/cgv/ApplicationTests.java @@ -0,0 +1,15 @@ +package com.ceos23.cgv; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class ApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/com/ceos23/cgv/domain/cinema/service/ReviewServiceTest.java b/src/test/java/com/ceos23/cgv/domain/cinema/service/ReviewServiceTest.java new file mode 100644 index 00000000..54b06aae --- /dev/null +++ b/src/test/java/com/ceos23/cgv/domain/cinema/service/ReviewServiceTest.java @@ -0,0 +1,84 @@ +package com.ceos23.cgv.domain.cinema.service; + +import com.ceos23.cgv.domain.cinema.dto.ReviewCreateRequest; +import com.ceos23.cgv.domain.cinema.entity.Review; +import com.ceos23.cgv.domain.cinema.enums.TheaterType; +import com.ceos23.cgv.domain.cinema.repository.ReviewRepository; +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.domain.movie.repository.MovieRepository; +import com.ceos23.cgv.domain.user.entity.User; +import com.ceos23.cgv.domain.user.repository.UserRepository; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ReviewServiceTest { + + @Mock + private ReviewRepository reviewRepository; + @Mock + private UserRepository userRepository; + @Mock + private MovieRepository movieRepository; + + @InjectMocks + private ReviewService reviewService; + + @Test + @DisplayName("관람평(Review) 작성 성공 테스트") + void createReview_Success() { + // Given + ReviewCreateRequest request = new ReviewCreateRequest(1L, 1L, TheaterType.IMAX, "IMAX로 보니 작화가 엄청나네요!"); + User user = User.builder().id(1L).nickname("우혁").build(); + Movie movie = Movie.builder().id(1L).title("원피스 필름 레드").build(); + + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(movieRepository.findById(1L)).willReturn(Optional.of(movie)); + given(reviewRepository.save(any(Review.class))).willAnswer(i -> i.getArgument(0)); + + // When + Review savedReview = reviewService.createReview(request); + + // Then + assertThat(savedReview.getUser().getNickname()).isEqualTo("우혁"); + assertThat(savedReview.getMovie().getTitle()).isEqualTo("원피스 필름 레드"); + assertThat(savedReview.getContent()).isEqualTo("IMAX로 보니 작화가 엄청나네요!"); + assertThat(savedReview.getType()).isEqualTo(TheaterType.IMAX); + assertThat(savedReview.getLikeCount()).isEqualTo(0); // 기본값 확인 + + verify(reviewRepository).save(any(Review.class)); + } + + @Test + @DisplayName("존재하지 않는 영화에 관람평 작성 시 MOVIE_NOT_FOUND 예외 발생") + void createReview_Fail_MovieNotFound() { + // Given + ReviewCreateRequest request = new ReviewCreateRequest(1L, 999L, TheaterType.NORMAL, "테스트 리뷰"); + User user = User.builder().id(1L).nickname("우혁").build(); + + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + // 영화 조회가 안 되는 상황 가정 + given(movieRepository.findById(999L)).willReturn(Optional.empty()); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> { + reviewService.createReview(request); + }); + + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.MOVIE_NOT_FOUND); + } +} \ No newline at end of file diff --git a/src/test/java/com/ceos23/cgv/domain/concession/service/ConcessionServiceTest.java b/src/test/java/com/ceos23/cgv/domain/concession/service/ConcessionServiceTest.java new file mode 100644 index 00000000..7fdd219b --- /dev/null +++ b/src/test/java/com/ceos23/cgv/domain/concession/service/ConcessionServiceTest.java @@ -0,0 +1,329 @@ +package com.ceos23.cgv.domain.concession.service; + +import com.ceos23.cgv.domain.cinema.entity.Cinema; +import com.ceos23.cgv.domain.cinema.repository.CinemaRepository; +import com.ceos23.cgv.domain.concession.dto.FoodOrderRequest; +import com.ceos23.cgv.domain.concession.entity.FoodOrder; +import com.ceos23.cgv.domain.concession.entity.Inventory; +import com.ceos23.cgv.domain.concession.entity.Product; +import com.ceos23.cgv.domain.concession.enums.FoodOrderStatus; +import com.ceos23.cgv.domain.concession.repository.FoodOrderRepository; +import com.ceos23.cgv.domain.concession.repository.InventoryRepository; +import com.ceos23.cgv.domain.concession.repository.OrderItemRepository; +import com.ceos23.cgv.domain.concession.repository.ProductRepository; +import com.ceos23.cgv.domain.payment.service.PaymentService; +import com.ceos23.cgv.domain.user.entity.User; +import com.ceos23.cgv.domain.user.repository.UserRepository; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.SimpleTransactionStatus; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ConcessionServiceTest { + + private static final String PAYMENT_ID = "food-order-payment-id"; + + @Mock + private FoodOrderRepository foodOrderRepository; + @Mock + private OrderItemRepository orderItemRepository; + @Mock + private UserRepository userRepository; + @Mock + private CinemaRepository cinemaRepository; + @Mock + private ProductRepository productRepository; + @Mock + private InventoryRepository inventoryRepository; + @Mock + private PaymentService paymentService; + @Mock + private PlatformTransactionManager transactionManager; + + private ConcessionService concessionService; + + @BeforeEach + void setUp() { + lenient().when(transactionManager.getTransaction(any(TransactionDefinition.class))) + .thenReturn(new SimpleTransactionStatus()); + + concessionService = new ConcessionService( + productRepository, + foodOrderRepository, + orderItemRepository, + userRepository, + cinemaRepository, + inventoryRepository, + paymentService, + transactionManager + ); + } + + @Test + @DisplayName("유저별 매점 주문 내역 조회 시 결제 완료 주문만 조회한다") + void getOrdersByUserId_Success_OnlyCompletedOrders() { + // Given + User user = User.builder().id(1L).nickname("우혁").build(); + Cinema cinema = Cinema.builder().id(1L).name("CGV 신촌").build(); + FoodOrder completedOrder = FoodOrder.builder() + .id(1L) + .user(user) + .cinema(cinema) + .totalPrice(13000) + .status(FoodOrderStatus.COMPLETED) + .paymentId(PAYMENT_ID) + .build(); + + given(foodOrderRepository.findByUserIdAndStatusWithFetchJoin(1L, FoodOrderStatus.COMPLETED)) + .willReturn(List.of(completedOrder)); + + // When + List orders = concessionService.getOrdersByUserId(1L); + + // Then + assertThat(orders).containsExactly(completedOrder); + verify(foodOrderRepository).findByUserIdAndStatusWithFetchJoin(1L, FoodOrderStatus.COMPLETED); + } + + @Test + @DisplayName("매점 주문 시 팝콘 2개와 콜라 1개의 총액이 정상적으로 계산되어 저장된다") + void createOrder_Success_TotalPriceCalculated() { + // Given (준비) + User user = User.builder().id(1L).nickname("우혁").build(); + Cinema cinema = Cinema.builder().id(1L).name("CGV 신촌").build(); + + // 팝콘(5,000원)과 콜라(3,000원) 엔티티 모킹 + Product popcorn = Product.builder().id(1L).name("달콤 팝콘").price(5000).build(); + Product cola = Product.builder().id(2L).name("콜라").price(3000).build(); + Inventory popcornInventory = Inventory.builder() + .id(1L).cinema(cinema).product(popcorn).stockQuantity(10).build(); + Inventory colaInventory = Inventory.builder() + .id(2L).cinema(cinema).product(cola).stockQuantity(10).build(); + + // 팝콘 2개, 콜라 1개 주문 요청 (기대 총액 = 5000*2 + 3000*1 = 13000원) + FoodOrderRequest request = new FoodOrderRequest( + 1L, 1L, + List.of( + new FoodOrderRequest.OrderItemRequest(1L, 2), // 팝콘 2개 + new FoodOrderRequest.OrderItemRequest(2L, 1) // 콜라 1개 + ) + ); + + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(cinemaRepository.findById(1L)).willReturn(Optional.of(cinema)); + given(productRepository.findAllById(List.of(1L, 2L))).willReturn(List.of(popcorn, cola)); + given(paymentService.createFoodOrderPaymentId()).willReturn(PAYMENT_ID); + given(inventoryRepository.findByCinemaIdAndProductIdForUpdate(1L, 1L)).willReturn(Optional.of(popcornInventory)); + given(inventoryRepository.findByCinemaIdAndProductIdForUpdate(1L, 2L)).willReturn(Optional.of(colaInventory)); + + // save 메서드 호출 시 인자로 넘어온 엔티티를 그대로 반환하도록 처리 + AtomicReference savedOrderRef = new AtomicReference<>(); + given(foodOrderRepository.save(any(FoodOrder.class))).willAnswer(invocation -> { + FoodOrder foodOrder = invocation.getArgument(0); + savedOrderRef.set(foodOrder); + return foodOrder; + }); + given(foodOrderRepository.findByPaymentId(PAYMENT_ID)).willAnswer(invocation -> + Optional.of(savedOrderRef.get())); + given(orderItemRepository.findByFoodOrderId(any())).willAnswer(invocation -> + List.of( + com.ceos23.cgv.domain.concession.entity.OrderItem.create(savedOrderRef.get(), popcorn, 2), + com.ceos23.cgv.domain.concession.entity.OrderItem.create(savedOrderRef.get(), cola, 1) + )); + + // When (실행) + FoodOrder savedOrder = concessionService.createOrder(request); + + // Then (검증) + assertThat(savedOrder.getTotalPrice()).isEqualTo(13000); + assertThat(savedOrder.getStatus()).isEqualTo(FoodOrderStatus.COMPLETED); + assertThat(savedOrder.getUser().getNickname()).isEqualTo("우혁"); + assertThat(popcornInventory.getStockQuantity()).isEqualTo(8); + assertThat(colaInventory.getStockQuantity()).isEqualTo(9); + + verify(foodOrderRepository).save(any(FoodOrder.class)); + verify(orderItemRepository).saveAll(anyList()); + verify(paymentService).requestInstantPayment(savedOrderRef.get()); + } + + @Test + @DisplayName("매점 재고 차감 시 productId 오름차순으로 락을 획득한다") + void createOrder_Success_DecreaseInventoryStocksInProductIdOrder() { + // Given + User user = User.builder().id(1L).nickname("우혁").build(); + Cinema cinema = Cinema.builder().id(1L).name("CGV 신촌").build(); + Product popcorn = Product.builder().id(1L).name("달콤 팝콘").price(5000).build(); + Product cola = Product.builder().id(2L).name("콜라").price(3000).build(); + Inventory popcornInventory = Inventory.builder() + .id(1L).cinema(cinema).product(popcorn).stockQuantity(10).build(); + Inventory colaInventory = Inventory.builder() + .id(2L).cinema(cinema).product(cola).stockQuantity(10).build(); + FoodOrderRequest request = new FoodOrderRequest( + 1L, 1L, + List.of( + new FoodOrderRequest.OrderItemRequest(2L, 1), + new FoodOrderRequest.OrderItemRequest(1L, 2) + ) + ); + + AtomicReference savedOrderRef = new AtomicReference<>(); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(cinemaRepository.findById(1L)).willReturn(Optional.of(cinema)); + given(productRepository.findAllById(List.of(2L, 1L))).willReturn(List.of(cola, popcorn)); + given(paymentService.createFoodOrderPaymentId()).willReturn(PAYMENT_ID); + given(foodOrderRepository.save(any(FoodOrder.class))).willAnswer(invocation -> { + FoodOrder foodOrder = invocation.getArgument(0); + savedOrderRef.set(foodOrder); + return foodOrder; + }); + given(foodOrderRepository.findByPaymentId(PAYMENT_ID)).willAnswer(invocation -> + Optional.of(savedOrderRef.get())); + given(orderItemRepository.findByFoodOrderId(any())).willAnswer(invocation -> + List.of( + com.ceos23.cgv.domain.concession.entity.OrderItem.create(savedOrderRef.get(), cola, 1), + com.ceos23.cgv.domain.concession.entity.OrderItem.create(savedOrderRef.get(), popcorn, 2) + )); + given(inventoryRepository.findByCinemaIdAndProductIdForUpdate(1L, 1L)).willReturn(Optional.of(popcornInventory)); + given(inventoryRepository.findByCinemaIdAndProductIdForUpdate(1L, 2L)).willReturn(Optional.of(colaInventory)); + + // When + concessionService.createOrder(request); + + // Then + var inOrder = inOrder(inventoryRepository); + inOrder.verify(inventoryRepository).findByCinemaIdAndProductIdForUpdate(1L, 1L); + inOrder.verify(inventoryRepository).findByCinemaIdAndProductIdForUpdate(1L, 2L); + } + + @Test + @DisplayName("매점 결제 실패 시 PENDING 주문을 취소하고 재고는 차감하지 않는다") + void createOrder_Fail_PaymentFailed() { + // Given + User user = User.builder().id(1L).nickname("우혁").build(); + Cinema cinema = Cinema.builder().id(1L).name("CGV 신촌").build(); + Product popcorn = Product.builder().id(1L).name("달콤 팝콘").price(5000).build(); + Inventory popcornInventory = Inventory.builder() + .id(1L).cinema(cinema).product(popcorn).stockQuantity(10).build(); + FoodOrderRequest request = new FoodOrderRequest( + 1L, 1L, + List.of(new FoodOrderRequest.OrderItemRequest(1L, 2)) + ); + + AtomicReference savedOrderRef = new AtomicReference<>(); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(cinemaRepository.findById(1L)).willReturn(Optional.of(cinema)); + given(productRepository.findAllById(List.of(1L))).willReturn(List.of(popcorn)); + given(paymentService.createFoodOrderPaymentId()).willReturn(PAYMENT_ID); + given(foodOrderRepository.save(any(FoodOrder.class))).willAnswer(invocation -> { + FoodOrder foodOrder = invocation.getArgument(0); + savedOrderRef.set(foodOrder); + return foodOrder; + }); + given(foodOrderRepository.findByPaymentId(PAYMENT_ID)).willAnswer(invocation -> + Optional.of(savedOrderRef.get())); + given(paymentService.requestInstantPayment(any(FoodOrder.class))) + .willThrow(new CustomException(ErrorCode.PAYMENT_FAILED)); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> + concessionService.createOrder(request) + ); + + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.PAYMENT_FAILED); + assertThat(savedOrderRef.get().getStatus()).isEqualTo(FoodOrderStatus.CANCELED); + assertThat(popcornInventory.getStockQuantity()).isEqualTo(10); + verify(inventoryRepository, never()).findByCinemaIdAndProductIdForUpdate(any(), any()); + } + + @Test + @DisplayName("매점 결제 성공 후 재고가 부족하면 외부 결제를 보상 취소하고 주문을 취소한다") + void createOrder_Fail_InventoryShortageAfterPayment() { + // Given + User user = User.builder().id(1L).nickname("우혁").build(); + Cinema cinema = Cinema.builder().id(1L).name("CGV 신촌").build(); + Product popcorn = Product.builder().id(1L).name("달콤 팝콘").price(5000).build(); + Inventory popcornInventory = Inventory.builder() + .id(1L).cinema(cinema).product(popcorn).stockQuantity(1).build(); + FoodOrderRequest request = new FoodOrderRequest( + 1L, 1L, + List.of(new FoodOrderRequest.OrderItemRequest(1L, 2)) + ); + + AtomicReference savedOrderRef = new AtomicReference<>(); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(cinemaRepository.findById(1L)).willReturn(Optional.of(cinema)); + given(productRepository.findAllById(List.of(1L))).willReturn(List.of(popcorn)); + given(paymentService.createFoodOrderPaymentId()).willReturn(PAYMENT_ID); + given(foodOrderRepository.save(any(FoodOrder.class))).willAnswer(invocation -> { + FoodOrder foodOrder = invocation.getArgument(0); + savedOrderRef.set(foodOrder); + return foodOrder; + }); + given(foodOrderRepository.findByPaymentId(PAYMENT_ID)).willAnswer(invocation -> + Optional.of(savedOrderRef.get())); + given(orderItemRepository.findByFoodOrderId(any())).willAnswer(invocation -> + List.of(com.ceos23.cgv.domain.concession.entity.OrderItem.create(savedOrderRef.get(), popcorn, 2))); + given(inventoryRepository.findByCinemaIdAndProductIdForUpdate(1L, 1L)).willReturn(Optional.of(popcornInventory)); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> + concessionService.createOrder(request) + ); + + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.INVENTORY_SHORTAGE); + assertThat(savedOrderRef.get().getStatus()).isEqualTo(FoodOrderStatus.CANCELED); + assertThat(popcornInventory.getStockQuantity()).isEqualTo(1); + verify(paymentService).cancelPayment(PAYMENT_ID); + } + + @Test + @DisplayName("존재하지 않는 매점 상품을 주문하려고 하면 PRODUCT_NOT_FOUND 예외가 발생한다") + void createOrder_Fail_ProductNotFound() { + // Given (준비) + User user = User.builder().id(1L).nickname("우혁").build(); + Cinema cinema = Cinema.builder().id(1L).name("CGV 신촌").build(); + + // 없는 상품(999번) 주문 요청 + FoodOrderRequest request = new FoodOrderRequest( + 1L, 1L, + List.of(new FoodOrderRequest.OrderItemRequest(999L, 1)) + ); + + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(cinemaRepository.findById(1L)).willReturn(Optional.of(cinema)); + given(productRepository.findAllById(List.of(999L))).willReturn(List.of()); + given(paymentService.createFoodOrderPaymentId()).willReturn(PAYMENT_ID); + given(foodOrderRepository.save(any(FoodOrder.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // When (실행) & Then (검증) + CustomException exception = assertThrows(CustomException.class, () -> { + concessionService.createOrder(request); + }); + + // 예외가 의도한 에러 코드(PRODUCT_NOT_FOUND)인지 확인 + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.PRODUCT_NOT_FOUND); + verify(paymentService, never()).requestInstantPayment(any(FoodOrder.class)); + } +} diff --git a/src/test/java/com/ceos23/cgv/domain/concession/service/InventoryServiceTest.java b/src/test/java/com/ceos23/cgv/domain/concession/service/InventoryServiceTest.java new file mode 100644 index 00000000..c35f4bf1 --- /dev/null +++ b/src/test/java/com/ceos23/cgv/domain/concession/service/InventoryServiceTest.java @@ -0,0 +1,110 @@ +package com.ceos23.cgv.domain.concession.service; + +import com.ceos23.cgv.domain.cinema.entity.Cinema; +import com.ceos23.cgv.domain.cinema.repository.CinemaRepository; +import com.ceos23.cgv.domain.concession.dto.InventoryUpdateRequest; +import com.ceos23.cgv.domain.concession.entity.Inventory; +import com.ceos23.cgv.domain.concession.entity.Product; +import com.ceos23.cgv.domain.concession.repository.InventoryRepository; +import com.ceos23.cgv.domain.concession.repository.ProductRepository; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class InventoryServiceTest { + + @Mock + private InventoryRepository inventoryRepository; + @Mock + private CinemaRepository cinemaRepository; + @Mock + private ProductRepository productRepository; + + @InjectMocks + private InventoryService inventoryService; + + @Test + @DisplayName("기존 재고가 있을 때 수량이 정상적으로 업데이트(차감) 된다") + void updateInventory_Success() { + // Given (준비) + InventoryUpdateRequest request = new InventoryUpdateRequest(1L, 1L, -5); // 5개 차감 요청 + + Cinema cinema = Cinema.builder().id(1L).name("CGV 강남").build(); + Product product = Product.builder().id(1L).name("고소팝콘").build(); + + // 기존에 재고가 10개 있다고 가정 + Inventory existingInventory = Inventory.builder() + .id(1L).cinema(cinema).product(product).stockQuantity(10).build(); + + given(cinemaRepository.findById(1L)).willReturn(Optional.of(cinema)); + given(productRepository.findById(1L)).willReturn(Optional.of(product)); + given(inventoryRepository.findByCinemaIdAndProductId(1L, 1L)).willReturn(Optional.of(existingInventory)); + + // When (실행) + Inventory updatedInventory = inventoryService.updateInventory(request); + + // Then (검증) + assertThat(updatedInventory.getStockQuantity()).isEqualTo(5); + } + + @Test + @DisplayName("재고 차감 시 결과가 1 미만으로 떨어지면 INVENTORY_SHORTAGE 예외가 발생한다") + void updateInventory_Fail_Shortage() { + // Given (준비) + InventoryUpdateRequest request = new InventoryUpdateRequest(1L, 1L, -10); // 10개 차감 요청 (재고 부족 상황 유도) + + Cinema cinema = Cinema.builder().id(1L).name("CGV 강남").build(); + Product product = Product.builder().id(1L).name("고소팝콘").build(); + + // 기존 재고는 5개밖에 없음 + Inventory existingInventory = Inventory.builder() + .id(1L).cinema(cinema).product(product).stockQuantity(5).build(); + + given(cinemaRepository.findById(1L)).willReturn(Optional.of(cinema)); + given(productRepository.findById(1L)).willReturn(Optional.of(product)); + given(inventoryRepository.findByCinemaIdAndProductId(1L, 1L)).willReturn(Optional.of(existingInventory)); + + // When (실행) & Then (검증) + CustomException exception = assertThrows(CustomException.class, () -> { + inventoryService.updateInventory(request); + }); + + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.INVENTORY_SHORTAGE); + } + + @Test + @DisplayName("처음 입고되는 상품의 수량이 1 미만(0 또는 음수)이면 INVALID_STOCK_QUANTITY 예외가 발생한다") + void updateInventory_Fail_NewShortage() { + // Given (준비) + InventoryUpdateRequest request = new InventoryUpdateRequest(1L, 1L, 0); // 0개 입고 요청 + + Cinema cinema = Cinema.builder().id(1L).name("CGV 강남").build(); + Product product = Product.builder().id(1L).name("고소팝콘").build(); + + given(cinemaRepository.findById(1L)).willReturn(Optional.of(cinema)); + given(productRepository.findById(1L)).willReturn(Optional.of(product)); + // 기존 재고가 아예 없다고 가정 (Optional.empty 반환) + given(inventoryRepository.findByCinemaIdAndProductId(1L, 1L)).willReturn(Optional.empty()); + + // When (실행) & Then (검증) + CustomException exception = assertThrows(CustomException.class, () -> { + inventoryService.updateInventory(request); + }); + + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.INVALID_STOCK_QUANTITY); + } +} diff --git a/src/test/java/com/ceos23/cgv/domain/movie/service/MovieServiceTest.java b/src/test/java/com/ceos23/cgv/domain/movie/service/MovieServiceTest.java new file mode 100644 index 00000000..48637e42 --- /dev/null +++ b/src/test/java/com/ceos23/cgv/domain/movie/service/MovieServiceTest.java @@ -0,0 +1,82 @@ +package com.ceos23.cgv.domain.movie.service; + +import com.ceos23.cgv.domain.movie.dto.MovieCreateRequest; +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.domain.movie.enums.Genre; +import com.ceos23.cgv.domain.movie.enums.MovieRating; +import com.ceos23.cgv.domain.movie.repository.MovieRepository; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class MovieServiceTest { + + @Mock + private MovieRepository movieRepository; + + @InjectMocks + private MovieService movieService; + + @Test + @DisplayName("영화를 정상적으로 등록한다") + void createMovie_Success() { + // Given + MovieCreateRequest request = new MovieCreateRequest( + "아바타: 물의 길", + 192, + LocalDate.of(2022, 12, 14), + MovieRating.ALL, + Genre.SF, + "판도라 행성에서 벌어지는 새로운 이야기..." + ); + + given(movieRepository.save(any(Movie.class))).willAnswer(i -> i.getArgument(0)); + + // When + Movie savedMovie = movieService.createMovie( + request.title(), + request.runningTime(), + request.releaseDate(), + request.movieRating(), + request.genre(), + request.prologue() + ); + + // Then + assertThat(savedMovie.getTitle()).isEqualTo("아바타: 물의 길"); + assertThat(savedMovie.getRunningTime()).isEqualTo(192); + assertThat(savedMovie.getGenre()).isEqualTo(Genre.SF); + + verify(movieRepository).save(any(Movie.class)); + } + + @Test + @DisplayName("존재하지 않는 영화 조회 시 MOVIE_NOT_FOUND 예외가 발생한다") + void getMovie_Fail_NotFound() { + // Given + Long invalidMovieId = 999L; + given(movieRepository.findById(invalidMovieId)).willReturn(Optional.empty()); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> { + movieService.getMovieDetails(invalidMovieId); + }); + + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.MOVIE_NOT_FOUND); + } +} \ No newline at end of file diff --git a/src/test/java/com/ceos23/cgv/domain/reservation/service/ReservationServiceTest.java b/src/test/java/com/ceos23/cgv/domain/reservation/service/ReservationServiceTest.java new file mode 100644 index 00000000..a7d94fa2 --- /dev/null +++ b/src/test/java/com/ceos23/cgv/domain/reservation/service/ReservationServiceTest.java @@ -0,0 +1,253 @@ +package com.ceos23.cgv.domain.reservation.service; + +import com.ceos23.cgv.domain.cinema.entity.Theater; +import com.ceos23.cgv.domain.cinema.enums.TheaterType; +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.domain.movie.entity.Screening; +import com.ceos23.cgv.domain.movie.repository.ScreeningRepository; +import com.ceos23.cgv.domain.payment.service.PaymentService; +import com.ceos23.cgv.domain.reservation.dto.ReservedSeatRequest; +import com.ceos23.cgv.domain.reservation.entity.Reservation; +import com.ceos23.cgv.domain.reservation.enums.Payment; +import com.ceos23.cgv.domain.reservation.enums.ReservationStatus; +import com.ceos23.cgv.domain.reservation.repository.ReservationRepository; +import com.ceos23.cgv.domain.reservation.repository.ReservedSeatRepository; +import com.ceos23.cgv.domain.user.entity.User; +import com.ceos23.cgv.domain.user.repository.UserRepository; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.SimpleTransactionStatus; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ReservationServiceTest { + + private static final String PAYMENT_ID = "reservation-payment-id"; + + @Mock + private ReservationRepository reservationRepository; + @Mock + private ReservedSeatRepository reservedSeatRepository; + @Mock + private UserRepository userRepository; + @Mock + private ScreeningRepository screeningRepository; + @Mock + private PaymentService paymentService; + @Mock + private PlatformTransactionManager transactionManager; + + @InjectMocks + private ReservationService reservationService; + + @BeforeEach + void setUpTransactionManager() { + given(transactionManager.getTransaction(any(TransactionDefinition.class))) + .willReturn(new SimpleTransactionStatus()); + } + + @Test + @DisplayName("예매 생성 시 좌석 선점 후 외부 결제가 성공하면 예매 상태가 COMPLETED가 된다") + void createReservation_Success() { + // Given + Long userId = 1L; + Long screeningId = 1L; + User user = User.builder().id(userId).nickname("우혁").build(); + Screening screening = createScreening(screeningId, TheaterType.NORMAL); + AtomicReference savedReservation = new AtomicReference<>(); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(screeningRepository.findByIdForUpdate(screeningId)).willReturn(Optional.of(screening)); + given(paymentService.createPaymentId()).willReturn(PAYMENT_ID); + given(reservationRepository.save(any(Reservation.class))).willAnswer(invocation -> { + Reservation reservation = invocation.getArgument(0); + savedReservation.set(reservation); + return reservation; + }); + given(reservationRepository.findByPaymentId(PAYMENT_ID)).willAnswer(invocation -> + Optional.of(savedReservation.get())); + + // When + Reservation reservation = reservationService.createReservation( + userId, + screeningId, + 2, + Payment.APP_CARD, + null, + seats() + ); + + // Then + assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.COMPLETED); + assertThat(reservation.getPaymentId()).isEqualTo(PAYMENT_ID); + verify(reservedSeatRepository).saveAll(anyList()); + verify(paymentService).requestInstantPayment(savedReservation.get()); + } + + @Test + @DisplayName("결제 실패 시 PENDING 예매를 취소하고 선점 좌석을 복구한다") + void createReservation_Fail_PaymentFailed() { + // Given + Long userId = 1L; + Long screeningId = 1L; + User user = User.builder().id(userId).nickname("우혁").build(); + Screening screening = createScreening(screeningId, TheaterType.NORMAL); + AtomicReference savedReservation = new AtomicReference<>(); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(screeningRepository.findByIdForUpdate(screeningId)).willReturn(Optional.of(screening)); + given(paymentService.createPaymentId()).willReturn(PAYMENT_ID); + given(reservationRepository.save(any(Reservation.class))).willAnswer(invocation -> { + Reservation reservation = invocation.getArgument(0); + savedReservation.set(reservation); + return reservation; + }); + given(reservationRepository.findByPaymentId(PAYMENT_ID)).willAnswer(invocation -> + Optional.of(savedReservation.get())); + given(paymentService.requestInstantPayment(any(Reservation.class))) + .willThrow(new CustomException(ErrorCode.PAYMENT_FAILED)); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> reservationService.createReservation( + userId, + screeningId, + 2, + Payment.APP_CARD, + null, + seats() + )); + + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.PAYMENT_FAILED); + assertThat(savedReservation.get().getStatus()).isEqualTo(ReservationStatus.CANCELED); + verify(reservedSeatRepository).deleteAllByReservationId(savedReservation.get().getId()); + } + + @Test + @DisplayName("좌석 저장 중 중복 좌석이 감지되면 결제를 요청하지 않고 SEAT_ALREADY_RESERVED 예외가 발생한다") + void createReservation_Fail_AlreadyReservedSeat() { + // Given + Long userId = 1L; + Long screeningId = 1L; + User user = User.builder().id(userId).nickname("우혁").build(); + Screening screening = createScreening(screeningId, TheaterType.NORMAL); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(screeningRepository.findByIdForUpdate(screeningId)).willReturn(Optional.of(screening)); + given(paymentService.createPaymentId()).willReturn(PAYMENT_ID); + given(reservationRepository.save(any(Reservation.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(reservedSeatRepository.saveAll(anyList())).willThrow(DataIntegrityViolationException.class); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> reservationService.createReservation( + userId, + screeningId, + 2, + Payment.APP_CARD, + null, + seats() + )); + + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.SEAT_ALREADY_RESERVED); + verify(paymentService, never()).requestInstantPayment(any(Reservation.class)); + } + + @Test + @DisplayName("결제 완료된 예매를 취소하면 외부 결제 취소 후 내부 예매와 좌석을 취소한다") + void cancelReservation_Success() { + // Given + Long userId = 1L; + Long reservationId = 1L; + User user = User.builder().id(userId).build(); + Reservation reservation = createCompletedReservation(reservationId, user); + + given(reservationRepository.findById(reservationId)).willReturn(Optional.of(reservation)); + + // When + reservationService.cancelReservation(userId, reservationId); + + // Then + assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.CANCELED); + verify(paymentService).cancelPayment(PAYMENT_ID); + verify(reservedSeatRepository).deleteAllByReservationId(reservationId); + } + + @Test + @DisplayName("존재하지 않는 상영일정으로 예매 시 SCREENING_NOT_FOUND 예외가 발생한다") + void createReservation_Fail_ScreeningNotFound() { + // Given + Long userId = 1L; + Long invalidScreeningId = 999L; + User user = User.builder().id(userId).nickname("우혁").build(); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(screeningRepository.findByIdForUpdate(invalidScreeningId)).willReturn(Optional.empty()); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> reservationService.createReservation( + userId, + invalidScreeningId, + 2, + Payment.APP_CARD, + null, + seats() + )); + + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.SCREENING_NOT_FOUND); + } + + private Reservation createCompletedReservation(Long reservationId, User user) { + Reservation reservation = Reservation.builder() + .id(reservationId) + .user(user) + .screening(createScreening(1L, TheaterType.NORMAL)) + .status(ReservationStatus.PENDING) + .peopleCount(2) + .price(30000) + .payment(Payment.APP_CARD) + .saleNumber("sale-number") + .paymentId(PAYMENT_ID) + .build(); + reservation.completePayment(); + return reservation; + } + + private Screening createScreening(Long screeningId, TheaterType theaterType) { + Movie movie = Movie.builder().id(1L).title("테스트 영화").build(); + Theater theater = Theater.builder().id(1L).name("1관").type(theaterType).build(); + + return Screening.builder() + .id(screeningId) + .movie(movie) + .theater(theater) + .isMorning(false) + .build(); + } + + private List seats() { + return List.of( + new ReservedSeatRequest.SeatInfo("G", 4), + new ReservedSeatRequest.SeatInfo("G", 5) + ); + } +} diff --git a/src/test/java/com/ceos23/cgv/domain/reservation/service/ReservedSeatServiceTest.java b/src/test/java/com/ceos23/cgv/domain/reservation/service/ReservedSeatServiceTest.java new file mode 100644 index 00000000..90c5b181 --- /dev/null +++ b/src/test/java/com/ceos23/cgv/domain/reservation/service/ReservedSeatServiceTest.java @@ -0,0 +1,68 @@ +package com.ceos23.cgv.domain.reservation.service; + +import com.ceos23.cgv.domain.movie.entity.Screening; +import com.ceos23.cgv.domain.movie.repository.ScreeningRepository; +import com.ceos23.cgv.domain.reservation.dto.ReservedSeatRequest; +import com.ceos23.cgv.domain.reservation.entity.Reservation; +import com.ceos23.cgv.domain.reservation.repository.ReservationRepository; +import com.ceos23.cgv.domain.reservation.repository.ReservedSeatRepository; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class ReservedSeatServiceTest { + + @Mock + private ReservedSeatRepository reservedSeatRepository; + @Mock + private ReservationRepository reservationRepository; + @Mock + private ScreeningRepository screeningRepository; + + @InjectMocks + private ReservedSeatService reservedSeatService; + + @Test + @DisplayName("이미 예매된 좌석 선택 시 SEAT_ALREADY_RESERVED 예외가 발생한다") + void createReservedSeats_Fail_AlreadyReserved() { + // Given (준비) + Long reservationId = 1L; + Long screeningId = 1L; + // G4, G5 좌석을 예매하려는 요청 생성 + ReservedSeatRequest request = new ReservedSeatRequest( + reservationId, screeningId, + List.of(new ReservedSeatRequest.SeatInfo("G", 4), new ReservedSeatRequest.SeatInfo("G", 5)) + ); + + Reservation reservation = Reservation.builder().id(reservationId).build(); + Screening screening = Screening.builder().id(screeningId).build(); + + given(reservationRepository.findById(reservationId)).willReturn(Optional.of(reservation)); + given(screeningRepository.findByIdForUpdate(screeningId)).willReturn(Optional.of(screening)); + + given(reservedSeatRepository.saveAll(anyList())).willThrow(DataIntegrityViolationException.class); + + // When (실행) & Then (검증) + CustomException exception = assertThrows(CustomException.class, () -> { + reservedSeatService.createReservedSeats(request); + }); + + // 의도한 대로 중복 예매 에러코드(R002)로 잘 변환되어 터지는지 확인 + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.SEAT_ALREADY_RESERVED); + } +} diff --git a/src/test/java/com/ceos23/cgv/domain/user/service/CinetalkServiceTest.java b/src/test/java/com/ceos23/cgv/domain/user/service/CinetalkServiceTest.java new file mode 100644 index 00000000..7dddb89f --- /dev/null +++ b/src/test/java/com/ceos23/cgv/domain/user/service/CinetalkServiceTest.java @@ -0,0 +1,88 @@ +package com.ceos23.cgv.domain.user.service; + +import com.ceos23.cgv.domain.cinema.entity.Cinema; +import com.ceos23.cgv.domain.cinema.repository.CinemaRepository; +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.domain.movie.repository.MovieRepository; +import com.ceos23.cgv.domain.user.entity.Cinetalk; +import com.ceos23.cgv.domain.user.entity.User; +import com.ceos23.cgv.domain.user.repository.CinetalkRepository; +import com.ceos23.cgv.domain.user.repository.UserRepository; +import com.ceos23.cgv.global.exception.CustomException; +import com.ceos23.cgv.global.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class CinetalkServiceTest { + + @Mock + private CinetalkRepository cinetalkRepository; + @Mock + private UserRepository userRepository; + @Mock + private MovieRepository movieRepository; + @Mock + private CinemaRepository cinemaRepository; + + @InjectMocks + private CinetalkService cinetalkService; + + @Test + @DisplayName("씨네톡 작성 성공 테스트") + void createCinetalk_Success() { + // Given (준비) + Long userId = 1L; + Long movieId = 1L; + String content = "정말 재밌는 영화였어요!"; + + User user = User.builder().id(userId).nickname("우혁").build(); + Movie movie = Movie.builder().id(movieId).title("아바타").build(); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(movieRepository.findById(movieId)).willReturn(Optional.of(movie)); + + given(cinetalkRepository.save(any(Cinetalk.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // When (실행) + Cinetalk savedCinetalk = cinetalkService.createCinetalk(userId, content, movieId, null); + + // Then (검증) + assertThat(savedCinetalk.getUser().getNickname()).isEqualTo("우혁"); + assertThat(savedCinetalk.getContent()).isEqualTo(content); + assertThat(savedCinetalk.getMovie().getTitle()).isEqualTo("아바타"); + assertThat(savedCinetalk.getLikeCount()).isEqualTo(0); // 초기 좋아요 수는 0이어야 함 + + // cinetalkRepository.save가 실제로 1번 호출되었는지 검증 + verify(cinetalkRepository).save(any(Cinetalk.class)); + } + + @Test + @DisplayName("존재하지 않는 유저로 씨네톡 작성 시 CustomException(USER_NOT_FOUND) 발생") + void createCinetalk_Fail_UserNotFound() { + // Given (준비) + Long invalidUserId = 999L; + + // DB에 유저가 없는 상황을 가정 (Optional.empty 반환) + given(userRepository.findById(invalidUserId)).willReturn(Optional.empty()); + + // When (실행) & Then (검증) + CustomException exception = assertThrows(CustomException.class, () -> { + cinetalkService.createCinetalk(invalidUserId, "테스트 내용", 1L, null); + }); + + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.USER_NOT_FOUND); + } +} \ No newline at end of file diff --git a/src/test/java/com/ceos23/cgv/global/cache/CachePerformanceTest.java b/src/test/java/com/ceos23/cgv/global/cache/CachePerformanceTest.java new file mode 100644 index 00000000..7254c9e2 --- /dev/null +++ b/src/test/java/com/ceos23/cgv/global/cache/CachePerformanceTest.java @@ -0,0 +1,117 @@ +package com.ceos23.cgv.global.cache; + +import com.ceos23.cgv.domain.movie.entity.Movie; +import com.ceos23.cgv.domain.movie.enums.Genre; +import com.ceos23.cgv.domain.movie.enums.MovieRating; +import com.ceos23.cgv.domain.movie.repository.MovieRepository; +import com.ceos23.cgv.domain.movie.service.MovieService; +import com.github.benmanes.caffeine.cache.stats.CacheStats; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +class CachePerformanceTest { + + private static final int MOVIE_COUNT = 30; + private static final int REPEAT_COUNT = 100; + + @Autowired + private MovieService movieService; + + @Autowired + private MovieRepository movieRepository; + + @Autowired + private CacheManager cacheManager; + + @BeforeEach + void setUp() { + clearMoviesCache(); + movieRepository.deleteAll(); + movieRepository.saveAll(IntStream.rangeClosed(1, MOVIE_COUNT) + .mapToObj(index -> Movie.create( + "테스트 영화 " + index, + 120, + LocalDate.of(2026, 1, 1), + MovieRating.ALL, + Genre.DRAMA, + "캐시 성능 측정용 영화" + )) + .toList()); + } + + @Test + @DisplayName("영화 목록 조회 캐시 적용 후 반복 조회 응답 시간과 적중률을 측정한다") + void measureMovieListCachePerformance() { + long coldElapsedNs = measureSingleCallWithEmptyCache(); + + clearMoviesCache(); + movieService.getAllMovies(); + + long warmElapsedNs = measureRepeatedWarmCalls(); + CacheStats stats = moviesNativeCache().stats(); + + double coldAvgMs = toMillis(coldElapsedNs); + double warmAvgMs = toMillis(warmElapsedNs) / REPEAT_COUNT; + double improvementRate = ((coldAvgMs - warmAvgMs) / coldAvgMs) * 100; + + System.out.printf( + "CACHE_PERFORMANCE movieCount=%d coldAvgMs=%.3f warmAvgMs=%.3f improvementRate=%.2f%% hitCount=%d missCount=%d hitRate=%.2f%%%n", + MOVIE_COUNT, + coldAvgMs, + warmAvgMs, + improvementRate, + stats.hitCount(), + stats.missCount(), + stats.hitRate() * 100 + ); + + assertThat(stats.hitCount()).isEqualTo(REPEAT_COUNT); + assertThat(stats.missCount()).isEqualTo(2); + assertThat(stats.hitRate()).isGreaterThan(0.98); + assertThat(warmAvgMs).isLessThan(coldAvgMs); + } + + private long measureSingleCallWithEmptyCache() { + long start = System.nanoTime(); + movieService.getAllMovies(); + return System.nanoTime() - start; + } + + private long measureRepeatedWarmCalls() { + long start = System.nanoTime(); + for (int index = 0; index < REPEAT_COUNT; index++) { + movieService.getAllMovies(); + } + return System.nanoTime() - start; + } + + private void clearMoviesCache() { + CaffeineCache cache = (CaffeineCache) cacheManager.getCache(CacheNames.MOVIES); + if (cache != null) { + cache.clear(); + cache.getNativeCache().policy().expireVariably(); + } + } + + private com.github.benmanes.caffeine.cache.Cache moviesNativeCache() { + return (com.github.benmanes.caffeine.cache.Cache) + ((CaffeineCache) cacheManager.getCache(CacheNames.MOVIES)).getNativeCache(); + } + + private double toMillis(long elapsedNs) { + return elapsedNs / 1_000_000.0; + } +} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 00000000..d9f2b27c --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1,23 @@ +spring: + datasource: + url: jdbc:h2:mem:cgv-test;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: + driver-class-name: org.h2.Driver + + jpa: + hibernate: + ddl-auto: create-drop + show-sql: false + properties: + hibernate: + format_sql: false + +jwt: + secret: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYwMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZg== + token-validity-in-seconds: 3600 + +payment: + base-url: http://localhost + store-id: test-store + api-secret-key: test-secret