From 792b2c6a8dc4a333c20c2af1af2b53a3e88c7cc5 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sun, 29 Dec 2024 16:02:02 +0900 Subject: [PATCH 001/162] merge test - generate performancehallentity.kt --- .../performance/persistence/PerformanceHallEntity.kt | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallEntity.kt diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallEntity.kt new file mode 100644 index 0000000..047feb4 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallEntity.kt @@ -0,0 +1,4 @@ +package com.wafflestudio.interpark.performance.persistence + +class PerformanceHallEntity { +} \ No newline at end of file From d3df36373c92caa45c79f6aed27e475a29470c1a Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sat, 4 Jan 2025 15:36:08 +0900 Subject: [PATCH 002/162] feat: searchPerformance --- .idea/codeStyles/Project.xml | 1 + .idea/material_theme_project_new.xml | 5 +- .../performance/controller/Performance.kt | 34 +++++++-- .../controller/PerformanceController.kt | 23 ++++++ .../persistence/PerformanceEntity.kt | 44 ++++++++++-- .../persistence/PerformanceHallEntity.kt | 20 +++++- .../persistence/PerformanceHallRepository.kt | 5 ++ .../persistence/PerformanceRepository.kt | 8 +++ .../persistence/PerformanceSpecifications.kt | 64 +++++++++++++++++ .../performance/service/PerformanceService.kt | 72 ++++++++++++++++++- .../review/controller/ReviewController.kt | 1 - .../review/persistence/ReviewEntity.kt | 3 +- .../interpark/review/service/ReviewService.kt | 3 +- .../seat/controller/SeatController.kt | 1 - .../user/persistence/UserIdentityEntity.kt | 9 ++- .../interpark/user/service/UserService.kt | 3 +- 16 files changed, 271 insertions(+), 25 deletions(-) create mode 100644 src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallRepository.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceRepository.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceSpecifications.kt diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 1bec35e..47a0546 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,6 +1,7 @@ + diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml index f7d735d..e9627a2 100644 --- a/.idea/material_theme_project_new.xml +++ b/.idea/material_theme_project_new.xml @@ -3,7 +3,10 @@ diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt index 19d09c3..2f14886 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt @@ -1,8 +1,32 @@ package com.wafflestudio.interpark.performance.controller -data class Performance ( - val title: String, - -){ +import com.wafflestudio.interpark.performance.persistence.PerformanceEntity +import java.time.LocalDate -} \ No newline at end of file +data class Performance( + val id: String, + val title: String, + val hallName: String, + val dates: List, + val genre: String, + val detail: String, + val sales: Int, + val posterUrl: String, + val backdropUrl: String, +) { + companion object { + fun fromEntity(entity: PerformanceEntity): Performance { + return Performance( + id = entity.id ?: "", + title = entity.title, + hallName = entity.hall.name, + dates = entity.dates, + genre = entity.genre, + detail = entity.detail ?: "", + sales = entity.sales, + posterUrl = entity.posterUrl, + backdropUrl = entity.backdropUrl, + ) + } + } +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index 883ee03..aa687d5 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -1,2 +1,25 @@ package com.wafflestudio.interpark.performance.controller +import com.wafflestudio.interpark.performance.service.PerformanceService +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.time.LocalDate + +@RestController +class PerformanceController( + private val performanceService: PerformanceService, +) { + @GetMapping("v1/performance/search") + fun searchPerformance( + @RequestParam keyword: String, + @RequestParam sortType: Int, + @RequestParam(required = false) date: LocalDate?, + @RequestParam(required = false) region: String?, + @RequestParam(required = false) genre: String?, + ): ResponseEntity> { + val queriedPerformances = performanceService.searchPerformance(keyword, sortType, date, region, genre) + return ResponseEntity.ok(queriedPerformances) + } +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt index 75744f9..cd920ed 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt @@ -1,12 +1,46 @@ package com.wafflestudio.interpark.performance.persistence -import jakarta.persistence.* +import jakarta.persistence.CollectionTable +import jakarta.persistence.Column +import jakarta.persistence.ElementCollection +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import java.time.LocalDate @Entity(name = "performances") -class PerformanceEntity ( +class PerformanceEntity( @Id @GeneratedValue(strategy = GenerationType.UUID) val id: String? = null, - - val detail: String? = null -) \ No newline at end of file + @ManyToOne + @JoinColumn(name = "hall_id", nullable = false) + var hall: PerformanceHallEntity, + @Column(nullable = false) + var title: String, + @Column(columnDefinition = "TEXT") + var detail: String? = null, + @Column(nullable = false) + var genre: String, + @Column(nullable = false) + var sales: Int = 0, + @ElementCollection + @CollectionTable(name = "performance_dates", joinColumns = [JoinColumn(name = "performance_id")]) + @Column(name = "date", nullable = false) + var dates: List = mutableListOf(), + @Column(name = "poster_url", nullable = false) + var posterUrl: String, + @Column(name = "backdrop_url", nullable = false) + var backdropUrl: String, + @ElementCollection + @CollectionTable(name = "performance_seats", joinColumns = [JoinColumn(name = "performance_id")]) + @Column(name = "seat_id", nullable = false) + var seatIds: List = mutableListOf(), + @ElementCollection + @CollectionTable(name = "performance_reviews", joinColumns = [JoinColumn(name = "performance_id")]) + @Column(name = "review_id", nullable = false) + var reviewIds: List = mutableListOf(), +) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallEntity.kt index 047feb4..4b4297a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallEntity.kt @@ -1,4 +1,20 @@ package com.wafflestudio.interpark.performance.persistence -class PerformanceHallEntity { -} \ No newline at end of file +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "performance_hall") +class PerformanceHallEntity( + @Id + @GeneratedValue(strategy = GenerationType.UUID) + val id: String? = null, + @Column(nullable = false) + var name: String, + @Column(nullable = false) + var address: String, +) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallRepository.kt new file mode 100644 index 0000000..dd78171 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallRepository.kt @@ -0,0 +1,5 @@ +package com.wafflestudio.interpark.performance.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface PerformanceHallRepository : JpaRepository diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceRepository.kt new file mode 100644 index 0000000..d0965a8 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceRepository.kt @@ -0,0 +1,8 @@ +package com.wafflestudio.interpark.performance.persistence + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.JpaSpecificationExecutor + +interface PerformanceRepository : + JpaRepository, + JpaSpecificationExecutor diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceSpecifications.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceSpecifications.kt new file mode 100644 index 0000000..007d4e1 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceSpecifications.kt @@ -0,0 +1,64 @@ +package com.wafflestudio.interpark.performance.persistence + +import jakarta.persistence.criteria.JoinType +import org.springframework.data.jpa.domain.Specification +import java.time.LocalDate + +object PerformanceSpecifications { + /** + * 키워드(검색어) 필수: PerformanceEntity.title 또는 detail 에 매칭되는지 + * - 정확도 순이라는 것이 '키워드 매칭'을 의미한다고 가정 + */ + fun withKeyword(keyword: String): Specification { + return Specification { root, query, cb -> + if (keyword.isBlank()) { + // 검색어가 비어있다면 전체 반환(조건 없음) + cb.conjunction() + } else { + val likeKeyword = "%$keyword%" + cb.or( + cb.like(root.get("title"), likeKeyword), + cb.like(root.get("detail"), likeKeyword), + ) + } + } + } + + /** + * 날짜 필터(선택): 해당 날짜를 포함하는 공연이 있는지 + * - date가 공연 dates 컬렉션에 포함되어 있어야 함 + * - 특정 날짜가 아닌, 날짜 범위가 필요하다면 추가로 수정 + */ + fun withDate(date: LocalDate?): Specification? { + if (date == null) return null + + return Specification { root, query, cb -> + // dates 컬렉션에 date 값이 포함되어 있으면 true + cb.isMember(date, root.get("dates")) + } + } + + /** + * 지역 필터(선택): PerformanceHallEntity의 address 컬럼 안에 region 문자열이 포함 + */ + fun withRegion(region: String?): Specification? { + if (region.isNullOrBlank()) return null + + return Specification { root, query, cb -> + val hallJoin = root.join("hall", JoinType.LEFT) + cb.like(hallJoin.get("address"), "%$region%") + } + } + + /** + * 장르 필터(선택): 이제 PerformanceEntity의 genre 컬럼을 직접 비교 + * (예: 부분 매칭으로 하려면 cb.like(root.get("genre"), "%$genre%") 로 수정) + */ + fun withGenre(genre: String?): Specification? { + if (genre.isNullOrBlank()) return null + + return Specification { root, _, cb -> + cb.equal(root.get("genre"), genre) + } + } +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt index ffe2500..983e4c4 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt @@ -1,3 +1,73 @@ package com.wafflestudio.interpark.performance.service -class PerformanceService +import com.wafflestudio.interpark.performance.controller.Performance +import com.wafflestudio.interpark.performance.persistence.PerformanceEntity +import com.wafflestudio.interpark.performance.persistence.PerformanceRepository +import com.wafflestudio.interpark.performance.persistence.PerformanceSpecifications +import org.springframework.data.domain.Sort +import org.springframework.data.jpa.domain.Specification +import java.time.LocalDate + +class PerformanceService( + private val performanceRepository: PerformanceRepository, +) { + fun searchPerformance( + keyword: String, + sortType: Int, + date: LocalDate?, + region: String?, + genre: String?, + ): List { + // 기본 스펙(키워드 검색은 필수) + var spec: Specification = Specification.where(PerformanceSpecifications.withKeyword(keyword)) + + // 날짜가 있으면 스펙 추가 + PerformanceSpecifications.withDate(date)?.let { + spec = spec.and(it) + } + + // 지역이 있으면 스펙 추가 + PerformanceSpecifications.withRegion(region)?.let { + spec = spec.and(it) + } + + // 장르가 있으면 스펙 추가 + PerformanceSpecifications.withGenre(genre)?.let { + spec = spec.and(it) + } + + // 정렬 기준 + // 1. 정확도순: 일단 예시상 '정확도'라는 것은 키워드 매칭 스코어 기반 정렬이 필요하지만, + // 여기서는 정렬로 처리하기 애매하므로 title 기준 정렬 or 별도 검색엔진(ElasticSearch)이 필요. + // 간단히 title 오름차순 정렬로 가정하겠습니다. + // 2. 공연임박순: dates 중 가장 빠른 날짜를 기준으로 오름차순 정렬 + // 3. 많이 팔린 순 : 판매량(sales) 기준 내림차순 정렬 (실제로 sales라는 컬럼이 있어야 함) + // + // 실무에서는 스펙으로 정렬을 구현할 수도 있고, Sort 객체를 사용할 수도 있습니다. + val sort = + when (sortType) { + 1 -> Sort.by(Sort.Direction.ASC, "title") // 간단히 title로 '정확도' 대체 + 2 -> Sort.by(Sort.Direction.ASC, "dates") // 공연일자 중 가장 빠른 날짜 + 3 -> Sort.by(Sort.Direction.DESC, "sales") // 'sales' 컬럼이 있다고 가정 + else -> Sort.by(Sort.Direction.ASC, "title") // 기본 정렬 + } + + // repository 호출 + val performanceEntities = performanceRepository.findAll(spec, sort) + + // DTO 변환 + return performanceEntities.map { Performance.fromEntity(it) } + } + + fun createPerformance(performance: Performance): Performance { + TODO() + } + + fun editPerformance(performance: Performance): Performance { + TODO() + } + + fun deletePerformance(performance: Performance) { + TODO() + } +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt index b54e4df..a3a90c6 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt @@ -1,2 +1 @@ package com.wafflestudio.interpark.review.controller - diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt index ef3ed2f..7ae004e 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt @@ -1,4 +1,3 @@ package com.wafflestudio.interpark.review.persistence -class ReviewEntity { -} \ No newline at end of file +class ReviewEntity diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt index 5d2f7c7..16311e2 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt @@ -1,4 +1,3 @@ package com.wafflestudio.interpark.review.service -class ReviewService { -} \ No newline at end of file +class ReviewService diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt index 31814a9..1fc46b6 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt @@ -1,2 +1 @@ package com.wafflestudio.interpark.seat.controller - diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt index fb66c54..75d474b 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt @@ -1,6 +1,10 @@ package com.wafflestudio.interpark.user.persistence -import jakarta.persistence.* +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id @Entity class UserIdentityEntity( @@ -9,5 +13,4 @@ class UserIdentityEntity( val id: String? = null, @Column(name = "hashedPassword", nullable = false) val hashedPassword: String, -) { -} \ No newline at end of file +) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt index 67be957..dcf22be 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt @@ -1,6 +1,5 @@ package com.wafflestudio.interpark.user.service -import com.wafflestudio.interpark.user.* import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.persistence.UserEntity import com.wafflestudio.interpark.user.persistence.UserRepository @@ -19,7 +18,7 @@ class UserService( phoneNumber: String, email: String, ): User { - //TODO : 회원가입 기능 만들기 + // TODO : 회원가입 기능 만들기 val user = userRepository.save( UserEntity( From 41189ce6ea9325da632ddf1d01e9ba3d6b148763 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Wed, 8 Jan 2025 20:11:33 +0900 Subject: [PATCH 003/162] =?UTF-8?q?chore:=20=EB=8F=84=EC=BB=A4=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EC=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dockerfile 작성해서 도커 이미지 잘 만들어지고 http 파일로 테스트까지 가능한 상태입니다 --- Dockerfile | 4 ++++ docker-compose.yaml | 0 src/main/kotlin/Main.kt | 3 --- src/test/resources/UserApi.http | 5 +++++ 4 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yaml delete mode 100644 src/main/kotlin/Main.kt create mode 100644 src/test/resources/UserApi.http diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1c96812 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM ubuntu:latest +LABEL authors="LG" + +ENTRYPOINT ["top", "-b"] \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..e69de29 diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt deleted file mode 100644 index 07bc6cd..0000000 --- a/src/main/kotlin/Main.kt +++ /dev/null @@ -1,3 +0,0 @@ -fun main() { - println("Hello World!") -} diff --git a/src/test/resources/UserApi.http b/src/test/resources/UserApi.http new file mode 100644 index 0000000..ec41a1b --- /dev/null +++ b/src/test/resources/UserApi.http @@ -0,0 +1,5 @@ +### GET request to example server +GET https://examples.http-client.intellij.net/get + ?generated-in=IntelliJ IDEA + +### \ No newline at end of file From 5f39ad097867512849b404a2e94b69d60effd786 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Wed, 8 Jan 2025 21:27:14 +0900 Subject: [PATCH 004/162] modify searchPerformance --- build.gradle.kts | 1 + .../controller/PerformanceController.kt | 18 ++++---- .../persistence/PerformanceEntity.kt | 2 +- .../persistence/PerformanceSpecifications.kt | 19 ++++----- .../performance/service/PerformanceService.kt | 42 ++++--------------- 5 files changed, 26 insertions(+), 56 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 668d91a..f0f050b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { implementation("io.jsonwebtoken:jjwt-impl:0.11.5") implementation("io.jsonwebtoken:jjwt-jackson:0.11.5") implementation("com.github.ben-manes.caffeine:caffeine:3.1.8") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0") testImplementation("org.springframework.boot:spring-boot-starter-test") } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index aa687d5..41bd506 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -5,7 +5,6 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController -import java.time.LocalDate @RestController class PerformanceController( @@ -13,13 +12,14 @@ class PerformanceController( ) { @GetMapping("v1/performance/search") fun searchPerformance( - @RequestParam keyword: String, - @RequestParam sortType: Int, - @RequestParam(required = false) date: LocalDate?, - @RequestParam(required = false) region: String?, - @RequestParam(required = false) genre: String?, - ): ResponseEntity> { - val queriedPerformances = performanceService.searchPerformance(keyword, sortType, date, region, genre) - return ResponseEntity.ok(queriedPerformances) + @RequestParam title: String?, + @RequestParam genre: String?, + ): ResponseEntity { + val queriedPerformances = performanceService.searchPerformance(title, genre) + return ResponseEntity.ok(SearchPerformanceResponse(performances = queriedPerformances)) } } + +data class SearchPerformanceResponse( + val performances: List +) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt index cd920ed..d647db5 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt @@ -17,7 +17,7 @@ class PerformanceEntity( @GeneratedValue(strategy = GenerationType.UUID) val id: String? = null, @ManyToOne - @JoinColumn(name = "hall_id", nullable = false) + @JoinColumn(name = "hall_id") var hall: PerformanceHallEntity, @Column(nullable = false) var title: String, diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceSpecifications.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceSpecifications.kt index 007d4e1..ea90a60 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceSpecifications.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceSpecifications.kt @@ -9,18 +9,13 @@ object PerformanceSpecifications { * 키워드(검색어) 필수: PerformanceEntity.title 또는 detail 에 매칭되는지 * - 정확도 순이라는 것이 '키워드 매칭'을 의미한다고 가정 */ - fun withKeyword(keyword: String): Specification { - return Specification { root, query, cb -> - if (keyword.isBlank()) { - // 검색어가 비어있다면 전체 반환(조건 없음) - cb.conjunction() - } else { - val likeKeyword = "%$keyword%" - cb.or( - cb.like(root.get("title"), likeKeyword), - cb.like(root.get("detail"), likeKeyword), - ) - } + fun withTitle(title: String?): Specification? { + if (title.isNullOrBlank()) { + // title이 없으면 조건없이(conjunction) 반환 + return null + } + return Specification { root, _, cb -> + cb.like(root.get("title"), "%$title%") } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt index 983e4c4..659321d 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt @@ -4,56 +4,30 @@ import com.wafflestudio.interpark.performance.controller.Performance import com.wafflestudio.interpark.performance.persistence.PerformanceEntity import com.wafflestudio.interpark.performance.persistence.PerformanceRepository import com.wafflestudio.interpark.performance.persistence.PerformanceSpecifications -import org.springframework.data.domain.Sort import org.springframework.data.jpa.domain.Specification -import java.time.LocalDate class PerformanceService( private val performanceRepository: PerformanceRepository, ) { fun searchPerformance( - keyword: String, - sortType: Int, - date: LocalDate?, - region: String?, + title: String?, genre: String?, ): List { - // 기본 스펙(키워드 검색은 필수) - var spec: Specification = Specification.where(PerformanceSpecifications.withKeyword(keyword)) + // 시작점: 아무 조건이 없는 스펙 + var spec: Specification = Specification.where(null) - // 날짜가 있으면 스펙 추가 - PerformanceSpecifications.withDate(date)?.let { + // title 조건이 있다면 스펙에 and로 연결 + PerformanceSpecifications.withTitle(title)?.let { spec = spec.and(it) } - // 지역이 있으면 스펙 추가 - PerformanceSpecifications.withRegion(region)?.let { - spec = spec.and(it) - } - - // 장르가 있으면 스펙 추가 + // genre 조건이 있다면 스펙에 and로 연결 PerformanceSpecifications.withGenre(genre)?.let { spec = spec.and(it) } - // 정렬 기준 - // 1. 정확도순: 일단 예시상 '정확도'라는 것은 키워드 매칭 스코어 기반 정렬이 필요하지만, - // 여기서는 정렬로 처리하기 애매하므로 title 기준 정렬 or 별도 검색엔진(ElasticSearch)이 필요. - // 간단히 title 오름차순 정렬로 가정하겠습니다. - // 2. 공연임박순: dates 중 가장 빠른 날짜를 기준으로 오름차순 정렬 - // 3. 많이 팔린 순 : 판매량(sales) 기준 내림차순 정렬 (실제로 sales라는 컬럼이 있어야 함) - // - // 실무에서는 스펙으로 정렬을 구현할 수도 있고, Sort 객체를 사용할 수도 있습니다. - val sort = - when (sortType) { - 1 -> Sort.by(Sort.Direction.ASC, "title") // 간단히 title로 '정확도' 대체 - 2 -> Sort.by(Sort.Direction.ASC, "dates") // 공연일자 중 가장 빠른 날짜 - 3 -> Sort.by(Sort.Direction.DESC, "sales") // 'sales' 컬럼이 있다고 가정 - else -> Sort.by(Sort.Direction.ASC, "title") // 기본 정렬 - } - - // repository 호출 - val performanceEntities = performanceRepository.findAll(spec, sort) + // 스펙이 결국 아무 조건도 없으면 -> 전체 검색 + val performanceEntities = performanceRepository.findAll(spec) // DTO 변환 return performanceEntities.map { Performance.fromEntity(it) } From c45a67c1a2d6ce8f2594d0d40f793d70dfea76ad Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Wed, 8 Jan 2025 21:37:43 +0900 Subject: [PATCH 005/162] add: docker-compose --- Dockerfile | 8 ++--- build.gradle.kts | 1 + docker-compose.yaml | 29 +++++++++++++++ .../user/controller/UserController.kt | 36 +++++++++++-------- .../interpark/user/persistence/UserEntity.kt | 1 - src/main/resources/application.yaml | 25 ++++++------- src/test/resources/UserApi.http | 31 +++++++++++++--- 7 files changed, 92 insertions(+), 39 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1c96812..6518354 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:latest -LABEL authors="LG" - -ENTRYPOINT ["top", "-b"] \ No newline at end of file +FROM openjdk:17 +COPY build/libs/Interpark_Back-0.0.1-SNAPSHOT.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "/app.jar"] \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 057168a..46d5de8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { implementation("io.jsonwebtoken:jjwt-impl:0.11.5") implementation("io.jsonwebtoken:jjwt-jackson:0.11.5") implementation("com.github.ben-manes.caffeine:caffeine:3.1.8") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0") testImplementation("org.springframework.boot:spring-boot-starter-test") runtimeOnly("com.h2database:h2") } diff --git a/docker-compose.yaml b/docker-compose.yaml index e69de29..46793f9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -0,0 +1,29 @@ +services: + mysql: + image: mysql:8.4 + container_name: mysql-db + ports: + - "3306:3306" + environment: + MYSQL_ROOT_PASSWORD: root_password + MYSQL_DATABASE: testdb + MYSQL_USER: user + MYSQL_PASSWORD: somepassword + networks: + - my-network + myapp: + image: myapp:1.0 + restart: on-failure + container_name: myapp-container + ports: + - "8080:8080" + depends_on: + - mysql + environment: + SPRING_DATASOURCE_URL: "jdbc:mysql://mysql-db:3306/testdb" + SPRING_DATASOURCE_USERNAME: "user" + SPRING_DATASOURCE_PASSWORD: "somepassword" + networks: + - my-network +networks: + my-network: diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt index a9f0cb7..9f309cd 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt @@ -11,6 +11,10 @@ import org.springframework.web.bind.annotation.* class UserController( private val userService: UserService, ) { + @GetMapping("/api/v1/ping") + fun ping() : ResponseEntity> { + return ResponseEntity.ok(mapOf("message" to "pong")) + } @PostMapping("/api/v1/signup") fun signup( @RequestBody request: SignUpRequest, @@ -33,13 +37,14 @@ class UserController( ): ResponseEntity { val (accessToken, refreshToken) = userService.signIn(request.username, request.password) - val cookie = Cookie("refreshToken", refreshToken).apply { - isHttpOnly = true - secure = true - path = "/api/v1/refresh_token" - maxAge = 60 * 60 * 24 * 7 - // TODO("domain 설정하기") - } + val cookie = + Cookie("refreshToken", refreshToken).apply { + isHttpOnly = true + secure = true + path = "/api/v1/refresh_token" + maxAge = 60 * 60 * 24 * 7 + // TODO("domain 설정하기") + } response.addCookie(cookie) return ResponseEntity.ok(TokenResponse(accessToken)) @@ -68,19 +73,20 @@ class UserController( @CookieValue(value = "refreshToken", required = false) refreshToken: String?, response: HttpServletResponse, ): ResponseEntity { - if(refreshToken == null) { + if (refreshToken == null) { throw TokenNotFoundException() } val (newAccessToken, newRefreshToken) = userService.refreshAccessToken(refreshToken) - val cookie = Cookie("refreshToken", newRefreshToken).apply { - isHttpOnly = true - secure = true - path = "/api/v1/refresh_token" - maxAge = 60 * 60 * 24 * 7 - // TODO("domain 설정하기") - } + val cookie = + Cookie("refreshToken", newRefreshToken).apply { + isHttpOnly = true + secure = true + path = "/api/v1/refresh_token" + maxAge = 60 * 60 * 24 * 7 + // TODO("domain 설정하기") + } response.addCookie(cookie) return ResponseEntity.ok(TokenResponse(newAccessToken)) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserEntity.kt index 2105738..9ddccb0 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserEntity.kt @@ -5,7 +5,6 @@ import jakarta.persistence.Entity import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id -import java.util.* @Entity class UserEntity( diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 1c6362b..4111ffc 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,20 +1,15 @@ spring: datasource: - url: 'jdbc:h2:mem:testdb;MODE=MySQL' - driver-class-name: org.h2.Driver - username: sa - password: 1234 + url: 'jdbc:mysql://localhost:3306/testdb' + driver-class-name: com.mysql.cj.jdbc.Driver + username: user + password: somepassword jpa: - database-platform: org.hibernate.dialect.H2Dialect hibernate: - ddl-auto: create + ddl-auto: create-drop + show-sql: true properties: - hibernate: - show_sql: true - format_sql: true - dialect: org.hibernate.dialect.H2Dialect - defer-datasource-initialization: true - h2: - console: - enabled: true - path: /h2-console \ No newline at end of file + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + format_sql: false + show_sql: false \ No newline at end of file diff --git a/src/test/resources/UserApi.http b/src/test/resources/UserApi.http index ec41a1b..f8a5475 100644 --- a/src/test/resources/UserApi.http +++ b/src/test/resources/UserApi.http @@ -1,5 +1,28 @@ -### GET request to example server -GET https://examples.http-client.intellij.net/get - ?generated-in=IntelliJ IDEA +### 회원가입 +POST http://localhost:8080/api/v1/signup +Content-Type: application/json -### \ No newline at end of file +{ + "username": "correct", + "password": "12345678", + "nickname": "examplename", + "phoneNumber": "010-0000-0000", + "email": "test@example.com" +} + +### 로그인 +POST http://localhost:8080/api/v1/signin +Content-Type: application/json + +{ + "username": "correct", + "password": "12345678" +} + +### pingpong +GET http://localhost:8080/api/v1/ping +Accept: application/json + +### 인증 토큰으로 유저 프로필 조회 +GET http://localhost:8080/api/v1/users/me +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJiY2JlMjI5Yi0zYjgyLTRkM2MtOWFjNS03ZWYzZTUyMjViNWYiLCJpYXQiOjE3MzYzMjA2MDksImV4cCI6MTczNjMyMTUwOX0.RJHC9qQvBnt_q4aS81qdpTrTnXfS-qQ-wMygRFXXg8E \ No newline at end of file From 446273a2341a66eb826f8efa42537a5322dacc95 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Wed, 8 Jan 2025 22:17:41 +0900 Subject: [PATCH 006/162] modify build.gradle.kts --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index f0f050b..8490309 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { kotlin("jvm") version "2.0.20" kotlin("plugin.spring") version "2.0.20" kotlin("plugin.jpa") version "2.0.20" - id("org.jlleitschuh.gradle.ktlint") version "12.1.1" + //id("org.jlleitschuh.gradle.ktlint") version "12.1.1" } group = "com.wafflestudio.interpark" From 0c146bf54dea2f429ea8ff0cff5a7620f61a11b7 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Thu, 9 Jan 2025 15:03:28 +0900 Subject: [PATCH 007/162] add wildcard import in UserService.kt --- .../com/wafflestudio/interpark/user/service/UserService.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt index 414efbe..f2fb16a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt @@ -1,5 +1,6 @@ package com.wafflestudio.interpark.user.service +import com.wafflestudio.interpark.user.* import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.persistence.UserEntity import com.wafflestudio.interpark.user.persistence.UserIdentityEntity From 1fc2ff8d153c6b311eff518b6184e2c04dc5ffd4 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Thu, 9 Jan 2025 15:36:26 +0900 Subject: [PATCH 008/162] change Performance attribute 'genre' to 'category' --- .../interpark/config/DataInitializer.kt | 83 +++++++++++++++++++ .../performance/controller/Performance.kt | 4 +- .../persistence/PerformanceEntity.kt | 2 +- .../persistence/PerformanceSpecifications.kt | 6 +- .../performance/service/PerformanceService.kt | 4 +- 5 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt new file mode 100644 index 0000000..45835ef --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -0,0 +1,83 @@ +package com.wafflestudio.interpark.config + +import com.wafflestudio.interpark.performance.persistence.PerformanceEntity +import com.wafflestudio.interpark.performance.persistence.PerformanceHallEntity +import com.wafflestudio.interpark.performance.persistence.PerformanceRepository +import com.wafflestudio.interpark.performance.persistence.PerformanceHallRepository +import org.springframework.boot.CommandLineRunner +import org.springframework.context.annotation.Configuration +import java.time.LocalDate + +@Configuration +class DataInitializer( + private val performanceHallRepository: PerformanceHallRepository, + private val performanceRepository: PerformanceRepository, +) : CommandLineRunner { + override fun run(vararg args: String?) { + // 1) 공연장(Hall) 데이터 넣기 + val hallA = performanceHallRepository.save( + PerformanceHallEntity( + name = "명동예술극장", + address = "서울 중구 명동" + ) + ) + val hallB = performanceHallRepository.save( + PerformanceHallEntity( + name = "대학로아트홀", + address = "서울 종로구 대학로" + ) + ) + val hallC = performanceHallRepository.save( + PerformanceHallEntity( + name = "LG아트센터 서울", + address = "서울 강서구 마곡중앙로" + ) + ) + + // 2) PerformanceEntity 여러개 생성 + val performanceList = listOf( + PerformanceEntity( + hall = hallA, + title = "뮤지컬 지킬앤하이드", + detail = "지킬과 하이드의 이중인격 이야기", + category = "뮤지컬", + sales = 1000, + dates = listOf(LocalDate.of(2025, 12, 25), LocalDate.of(2025, 12, 26)), + posterUrl = "http://example.com/poster/jekyll.jpg", + backdropUrl = "http://example.com/backdrop/jekyll.jpg", + seatIds = listOf("A1", "A2", "A3"), + reviewIds = listOf("review1", "review2") + ), + PerformanceEntity( + hall = hallA, + title = "오페라의 유령", + detail = "파리 오페라 극장에서 벌어지는 미스터리", + category = "뮤지컬", + sales = 2000, + dates = listOf(LocalDate.of(2025, 11, 5), LocalDate.of(2025, 11, 6)), + posterUrl = "http://example.com/poster/phantom.jpg", + backdropUrl = "http://example.com/backdrop/phantom.jpg", + seatIds = listOf("B1", "B2", "B3"), + reviewIds = listOf("review3", "review4") + ), + PerformanceEntity( + hall = hallB, + title = "친정엄마", + detail = "연극으로 만나는 엄마와 딸의 이야기", + category = "연극", + sales = 500, + dates = listOf(LocalDate.of(2026, 1, 10)), + posterUrl = "http://example.com/poster/mom.jpg", + backdropUrl = "http://example.com/backdrop/mom.jpg", + seatIds = listOf("C1", "C2", "C3"), + reviewIds = listOf("review5") + ) + // 필요한 만큼 추가... + ) + + // 3) 한번에 저장 + performanceRepository.saveAll(performanceList) + + // 이제 DB에 3개의 PerformanceEntity가 insert됨 + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt index 2f14886..c5dbf56 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt @@ -8,7 +8,7 @@ data class Performance( val title: String, val hallName: String, val dates: List, - val genre: String, + val category: String, val detail: String, val sales: Int, val posterUrl: String, @@ -21,7 +21,7 @@ data class Performance( title = entity.title, hallName = entity.hall.name, dates = entity.dates, - genre = entity.genre, + category = entity.category, detail = entity.detail ?: "", sales = entity.sales, posterUrl = entity.posterUrl, diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt index d647db5..259d4df 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt @@ -24,7 +24,7 @@ class PerformanceEntity( @Column(columnDefinition = "TEXT") var detail: String? = null, @Column(nullable = false) - var genre: String, + var category: String, @Column(nullable = false) var sales: Int = 0, @ElementCollection diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceSpecifications.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceSpecifications.kt index ea90a60..3b2cb8f 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceSpecifications.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceSpecifications.kt @@ -49,11 +49,11 @@ object PerformanceSpecifications { * 장르 필터(선택): 이제 PerformanceEntity의 genre 컬럼을 직접 비교 * (예: 부분 매칭으로 하려면 cb.like(root.get("genre"), "%$genre%") 로 수정) */ - fun withGenre(genre: String?): Specification? { - if (genre.isNullOrBlank()) return null + fun withCategory(category: String?): Specification? { + if (category.isNullOrBlank()) return null return Specification { root, _, cb -> - cb.equal(root.get("genre"), genre) + cb.equal(root.get("category"), category) } } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt index 30f9637..eaf5204 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt @@ -13,7 +13,7 @@ class PerformanceService( ) { fun searchPerformance( title: String?, - genre: String?, + category: String?, ): List { // 시작점: 아무 조건이 없는 스펙 var spec: Specification = Specification.where(null) @@ -24,7 +24,7 @@ class PerformanceService( } // genre 조건이 있다면 스펙에 and로 연결 - PerformanceSpecifications.withGenre(genre)?.let { + PerformanceSpecifications.withGenre(category)?.let { spec = spec.and(it) } From ae8470d4e4a812f6a154fc1871ed5f4bd4852063 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Thu, 9 Jan 2025 16:13:45 +0900 Subject: [PATCH 009/162] change category to ENUM Type --- .../interpark/config/DataInitializer.kt | 50 +++++++++---------- .../performance/controller/Performance.kt | 3 +- .../controller/PerformanceController.kt | 5 +- .../persistence/PerformanceEntity.kt | 18 ++++++- .../persistence/PerformanceSpecifications.kt | 6 +-- .../performance/service/PerformanceService.kt | 8 +-- 6 files changed, 55 insertions(+), 35 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index 45835ef..22d91cc 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -1,5 +1,6 @@ package com.wafflestudio.interpark.config +import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import com.wafflestudio.interpark.performance.persistence.PerformanceEntity import com.wafflestudio.interpark.performance.persistence.PerformanceHallEntity import com.wafflestudio.interpark.performance.persistence.PerformanceRepository @@ -15,64 +16,63 @@ class DataInitializer( ) : CommandLineRunner { override fun run(vararg args: String?) { // 1) 공연장(Hall) 데이터 넣기 - val hallA = performanceHallRepository.save( + val BlueSquare_ShinHanCardHall = performanceHallRepository.save( PerformanceHallEntity( - name = "명동예술극장", - address = "서울 중구 명동" + name = "블루스퀘어 신한카드홀", + address = "서울 용산구 한남동 이태원로" ) ) - val hallB = performanceHallRepository.save( + val SeoulArtsCenter_OperaHouse = performanceHallRepository.save( PerformanceHallEntity( - name = "대학로아트홀", - address = "서울 종로구 대학로" + name = "예술의전당 오페라극장", + address = "서울 서초구 남부순환로" ) ) - val hallC = performanceHallRepository.save( + val LGArtCenterSeoul_SIGNATUREHAll = performanceHallRepository.save( PerformanceHallEntity( - name = "LG아트센터 서울", - address = "서울 강서구 마곡중앙로" + name = "LG아트센터 서울 SIGNATURE홀", + address = "서울 강서구 마곡동 마곡중앙로" ) ) // 2) PerformanceEntity 여러개 생성 val performanceList = listOf( PerformanceEntity( - hall = hallA, + hall = BlueSquare_ShinHanCardHall, title = "뮤지컬 지킬앤하이드", - detail = "지킬과 하이드의 이중인격 이야기", - category = "뮤지컬", + detail = "지금 이 순간, 끝나지 않는 신화", + category = PerformanceCategory.MUSICAL, sales = 1000, - dates = listOf(LocalDate.of(2025, 12, 25), LocalDate.of(2025, 12, 26)), + dates = listOf(LocalDate.of(2024, 11, 29), LocalDate.of(2025, 5, 18)), posterUrl = "http://example.com/poster/jekyll.jpg", backdropUrl = "http://example.com/backdrop/jekyll.jpg", seatIds = listOf("A1", "A2", "A3"), reviewIds = listOf("review1", "review2") ), PerformanceEntity( - hall = hallA, - title = "오페라의 유령", - detail = "파리 오페라 극장에서 벌어지는 미스터리", - category = "뮤지컬", + hall = LGArtCenterSeoul_SIGNATUREHAll, + title = "마타하리", + detail = "She's BACK!", + category = PerformanceCategory.MUSICAL, sales = 2000, - dates = listOf(LocalDate.of(2025, 11, 5), LocalDate.of(2025, 11, 6)), + dates = listOf(LocalDate.of(2024, 12, 5), LocalDate.of(2025, 3, 2)), posterUrl = "http://example.com/poster/phantom.jpg", backdropUrl = "http://example.com/backdrop/phantom.jpg", seatIds = listOf("B1", "B2", "B3"), reviewIds = listOf("review3", "review4") ), PerformanceEntity( - hall = hallB, - title = "친정엄마", - detail = "연극으로 만나는 엄마와 딸의 이야기", - category = "연극", + hall = SeoulArtsCenter_OperaHouse, + title = "웃는남자", + detail = "부자들의 낙원은 가난한 자들의 지옥으로 세워진 것이다", + category = PerformanceCategory.MUSICAL, sales = 500, - dates = listOf(LocalDate.of(2026, 1, 10)), + dates = listOf(LocalDate.of(2025, 1, 9), LocalDate.of(2025, 3, 9)), posterUrl = "http://example.com/poster/mom.jpg", backdropUrl = "http://example.com/backdrop/mom.jpg", seatIds = listOf("C1", "C2", "C3"), reviewIds = listOf("review5") - ) - // 필요한 만큼 추가... + ), ) // 3) 한번에 저장 diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt index c5dbf56..7c4c927 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt @@ -1,5 +1,6 @@ package com.wafflestudio.interpark.performance.controller +import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import com.wafflestudio.interpark.performance.persistence.PerformanceEntity import java.time.LocalDate @@ -8,7 +9,7 @@ data class Performance( val title: String, val hallName: String, val dates: List, - val category: String, + val category: PerformanceCategory, val detail: String, val sales: Int, val posterUrl: String, diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index 41bd506..ee99ae2 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -1,5 +1,6 @@ package com.wafflestudio.interpark.performance.controller +import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import com.wafflestudio.interpark.performance.service.PerformanceService import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping @@ -13,9 +14,9 @@ class PerformanceController( @GetMapping("v1/performance/search") fun searchPerformance( @RequestParam title: String?, - @RequestParam genre: String?, + @RequestParam category: PerformanceCategory?, ): ResponseEntity { - val queriedPerformances = performanceService.searchPerformance(title, genre) + val queriedPerformances = performanceService.searchPerformance(title, category) return ResponseEntity.ok(SearchPerformanceResponse(performances = queriedPerformances)) } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt index 259d4df..80788e9 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt @@ -16,31 +16,47 @@ class PerformanceEntity( @Id @GeneratedValue(strategy = GenerationType.UUID) val id: String? = null, + @ManyToOne @JoinColumn(name = "hall_id") var hall: PerformanceHallEntity, + @Column(nullable = false) var title: String, @Column(columnDefinition = "TEXT") var detail: String? = null, + @Column(nullable = false) - var category: String, + var category: PerformanceCategory, + @Column(nullable = false) var sales: Int = 0, + @ElementCollection @CollectionTable(name = "performance_dates", joinColumns = [JoinColumn(name = "performance_id")]) @Column(name = "date", nullable = false) var dates: List = mutableListOf(), + @Column(name = "poster_url", nullable = false) var posterUrl: String, + @Column(name = "backdrop_url", nullable = false) var backdropUrl: String, + @ElementCollection @CollectionTable(name = "performance_seats", joinColumns = [JoinColumn(name = "performance_id")]) @Column(name = "seat_id", nullable = false) var seatIds: List = mutableListOf(), + @ElementCollection @CollectionTable(name = "performance_reviews", joinColumns = [JoinColumn(name = "performance_id")]) @Column(name = "review_id", nullable = false) var reviewIds: List = mutableListOf(), ) + +enum class PerformanceCategory { + MUSICAL, + CONCERT, + CLASSIC, + PLAY, +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceSpecifications.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceSpecifications.kt index 3b2cb8f..15e4956 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceSpecifications.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceSpecifications.kt @@ -49,11 +49,11 @@ object PerformanceSpecifications { * 장르 필터(선택): 이제 PerformanceEntity의 genre 컬럼을 직접 비교 * (예: 부분 매칭으로 하려면 cb.like(root.get("genre"), "%$genre%") 로 수정) */ - fun withCategory(category: String?): Specification? { - if (category.isNullOrBlank()) return null + fun withCategory(category: PerformanceCategory?): Specification? { + if (category == null) return null return Specification { root, _, cb -> - cb.equal(root.get("category"), category) + cb.equal(root.get("category"), category) } } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt index eaf5204..0947ca4 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt @@ -1,6 +1,7 @@ package com.wafflestudio.interpark.performance.service import com.wafflestudio.interpark.performance.controller.Performance +import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import com.wafflestudio.interpark.performance.persistence.PerformanceEntity import com.wafflestudio.interpark.performance.persistence.PerformanceRepository import com.wafflestudio.interpark.performance.persistence.PerformanceSpecifications @@ -13,7 +14,7 @@ class PerformanceService( ) { fun searchPerformance( title: String?, - category: String?, + category: PerformanceCategory?, ): List { // 시작점: 아무 조건이 없는 스펙 var spec: Specification = Specification.where(null) @@ -23,8 +24,9 @@ class PerformanceService( spec = spec.and(it) } - // genre 조건이 있다면 스펙에 and로 연결 - PerformanceSpecifications.withGenre(category)?.let { + // category 조건이 있다면 스펙에 and로 연결 + + PerformanceSpecifications.withCategory(category)?.let { spec = spec.and(it) } From 3fc5f41524a03ddce66a7eeef3d7965dff8e20a3 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Thu, 9 Jan 2025 16:48:40 +0900 Subject: [PATCH 010/162] add some Init Datas --- .../interpark/config/DataInitializer.kt | 40 +++++++++++++++++++ .../persistence/PerformanceEntity.kt | 3 ++ .../performance/service/PerformanceService.kt | 1 - 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index 22d91cc..acf4350 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -22,6 +22,12 @@ class DataInitializer( address = "서울 용산구 한남동 이태원로" ) ) + val BlueSquare_MasterCardHall = performanceHallRepository.save( + PerformanceHallEntity( + name = "블루스퀘어 마스터카드홀", + address = "서울 용산구 한남동 이태원로" + ) + ) val SeoulArtsCenter_OperaHouse = performanceHallRepository.save( PerformanceHallEntity( name = "예술의전당 오페라극장", @@ -34,9 +40,16 @@ class DataInitializer( address = "서울 강서구 마곡동 마곡중앙로" ) ) + val OlympicPark_OlympicHall = performanceHallRepository.save( + PerformanceHallEntity( + name = "올림픽공원 올림픽홀", + address = "서울 송파구 방이동" + ) + ) // 2) PerformanceEntity 여러개 생성 val performanceList = listOf( + // 뮤지컬 PerformanceEntity( hall = BlueSquare_ShinHanCardHall, title = "뮤지컬 지킬앤하이드", @@ -73,6 +86,33 @@ class DataInitializer( seatIds = listOf("C1", "C2", "C3"), reviewIds = listOf("review5") ), + // 콘서트 + PerformanceEntity( + hall = BlueSquare_MasterCardHall, + title = "2025 기리보이 콘서트", + detail = "2252:2522", + category = PerformanceCategory.CONCERT, + sales = 0, + dates = listOf(LocalDate.of(2025, 2, 1), LocalDate.of(2025, 2, 2)), + posterUrl = "http://example.com/poster/mom.jpg", + backdropUrl = "http://example.com/backdrop/mom.jpg", + seatIds = emptyList(), + reviewIds = emptyList() + ), + PerformanceEntity( + hall = BlueSquare_MasterCardHall, + title = "2025 검정치마 단독공연", + detail = "SONGS TO BRING YOU HOME", + category = PerformanceCategory.CONCERT, + sales = 0, + dates = listOf(LocalDate.of(2025, 2, 1), LocalDate.of(2025, 2, 2)), + posterUrl = "http://example.com/poster/mom.jpg", + backdropUrl = "http://example.com/backdrop/mom.jpg", + seatIds = emptyList(), + reviewIds = emptyList() + ), + // 클래식 + // 연극 ) // 3) 한번에 저장 diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt index 80788e9..aa9390b 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt @@ -4,6 +4,8 @@ import jakarta.persistence.CollectionTable import jakarta.persistence.Column import jakarta.persistence.ElementCollection import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id @@ -26,6 +28,7 @@ class PerformanceEntity( @Column(columnDefinition = "TEXT") var detail: String? = null, + @Enumerated(EnumType.STRING) @Column(nullable = false) var category: PerformanceCategory, diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt index 0947ca4..9b2a983 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt @@ -25,7 +25,6 @@ class PerformanceService( } // category 조건이 있다면 스펙에 and로 연결 - PerformanceSpecifications.withCategory(category)?.let { spec = spec.and(it) } From d0dffd25b6318a6a5ac1efbac0674f4c7f7e1d35 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Thu, 9 Jan 2025 17:33:08 +0900 Subject: [PATCH 011/162] add some Init Datas --- .../interpark/config/DataInitializer.kt | 121 +++++++++++++++++- 1 file changed, 118 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index acf4350..5a2a2a1 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -34,18 +34,48 @@ class DataInitializer( address = "서울 서초구 남부순환로" ) ) + val SeoulArtsCenter_ConcertHall = performanceHallRepository.save( + PerformanceHallEntity( + name = "예술의전당 콘서트홀", + address = "서울 서초구 남부순환로" + ) + ) val LGArtCenterSeoul_SIGNATUREHAll = performanceHallRepository.save( PerformanceHallEntity( name = "LG아트센터 서울 SIGNATURE홀", address = "서울 강서구 마곡동 마곡중앙로" ) ) + val LGArtCenterSeoul_UPlusStage = performanceHallRepository.save( + PerformanceHallEntity( + name = "LG아트센터 서울 U+ 스테이지", + address = "서울 강서구 마곡동 마곡중앙로" + ) + ) val OlympicPark_OlympicHall = performanceHallRepository.save( PerformanceHallEntity( name = "올림픽공원 올림픽홀", address = "서울 송파구 방이동" ) ) + val GoyangSportsComplex_MainStadium = performanceHallRepository.save( + PerformanceHallEntity( + name = "고양종합운동장 주경기장", + address = "경기도 고양시 일산서구 대화동 중앙로" + ) + ) + val SejongCenter_GrandTheater = performanceHallRepository.save( + PerformanceHallEntity( + name = "세종문화회관 대극장", + address = "서울 종로구 세종대로" + ) + ) + val SejongCenter_MTheater = performanceHallRepository.save( + PerformanceHallEntity( + name = "세종문화회관 M씨어터", + address = "서울 종로구 세종대로" + ) + ) // 2) PerformanceEntity 여러개 생성 val performanceList = listOf( @@ -86,6 +116,7 @@ class DataInitializer( seatIds = listOf("C1", "C2", "C3"), reviewIds = listOf("review5") ), + // 콘서트 PerformanceEntity( hall = BlueSquare_MasterCardHall, @@ -100,7 +131,7 @@ class DataInitializer( reviewIds = emptyList() ), PerformanceEntity( - hall = BlueSquare_MasterCardHall, + hall = OlympicPark_OlympicHall, title = "2025 검정치마 단독공연", detail = "SONGS TO BRING YOU HOME", category = PerformanceCategory.CONCERT, @@ -111,13 +142,97 @@ class DataInitializer( seatIds = emptyList(), reviewIds = emptyList() ), + PerformanceEntity( + hall = GoyangSportsComplex_MainStadium, + title = "콜드플레이 내한공연", + detail = "MUSIC of the SPHERES", + category = PerformanceCategory.CONCERT, + sales = 0, + dates = listOf(LocalDate.of(2025, 4, 16), LocalDate.of(2025, 4, 25)), + posterUrl = "http://example.com/poster/mom.jpg", + backdropUrl = "http://example.com/backdrop/mom.jpg", + seatIds = emptyList(), + reviewIds = emptyList() + ), + // 클래식 + PerformanceEntity( + hall = SeoulArtsCenter_ConcertHall, + title = "브루스 리우 피아노 리사이틀", + detail = "TCHAIKOVSKY | MENDELSSOHN | SCRIABIN | PROKOFIEV", + category = PerformanceCategory.CLASSIC, + sales = 0, + dates = listOf(LocalDate.of(2025, 5, 11)), + posterUrl = "http://example.com/poster/mom.jpg", + backdropUrl = "http://example.com/backdrop/mom.jpg", + seatIds = emptyList(), + reviewIds = emptyList() + ), + PerformanceEntity( + hall = SeoulArtsCenter_ConcertHall, + title = "크리스티안 테츨라프 바이올린 리사이틀", + detail = "SUK | BRAHMS | SZYMANOWSKI | FRANCK", + category = PerformanceCategory.CLASSIC, + sales = 0, + dates = listOf(LocalDate.of(2025, 5, 1)), + posterUrl = "http://example.com/poster/mom.jpg", + backdropUrl = "http://example.com/backdrop/mom.jpg", + seatIds = emptyList(), + reviewIds = emptyList() + ), + PerformanceEntity( + hall = SejongCenter_GrandTheater, + title = "발레의 별빛, 글로벌 발레스타 초청 갈라공연", + detail = "전 세계가 먼저 찾는 한국 스타 무용수들의 향연!", + category = PerformanceCategory.CLASSIC, + sales = 0, + dates = listOf(LocalDate.of(2025, 1, 11), LocalDate.of(2025, 1, 12)), + posterUrl = "http://example.com/poster/mom.jpg", + backdropUrl = "http://example.com/backdrop/mom.jpg", + seatIds = emptyList(), + reviewIds = emptyList() + ), + // 연극 + PerformanceEntity( + hall = LGArtCenterSeoul_UPlusStage, + title = "연극 애나엑스", + detail = "ANNA X", + category = PerformanceCategory.PLAY, + sales = 0, + dates = listOf(LocalDate.of(2025, 1, 28), LocalDate.of(2025, 3, 16)), + posterUrl = "http://example.com/poster/mom.jpg", + backdropUrl = "http://example.com/backdrop/mom.jpg", + seatIds = emptyList(), + reviewIds = emptyList() + ), + PerformanceEntity( + hall = LGArtCenterSeoul_UPlusStage, + title = "연극 타인의 삶", + detail = "영화 타인의 삶 원작", + category = PerformanceCategory.PLAY, + sales = 0, + dates = listOf(LocalDate.of(2025, 1, 28), LocalDate.of(2025, 3, 16)), + posterUrl = "http://example.com/poster/mom.jpg", + backdropUrl = "http://example.com/backdrop/mom.jpg", + seatIds = emptyList(), + reviewIds = emptyList() + ), + PerformanceEntity( + hall = SejongCenter_MTheater, + title = "세일즈맨의 죽음", + detail = "현 희곡의 거장 '아서 밀러'의 대표작 연극<세일즈맨의 죽음>이 돌아왔다!", + category = PerformanceCategory.PLAY, + sales = 0, + dates = listOf(LocalDate.of(2025, 1, 7), LocalDate.of(2025, 3, 3)), + posterUrl = "http://example.com/poster/mom.jpg", + backdropUrl = "http://example.com/backdrop/mom.jpg", + seatIds = emptyList(), + reviewIds = emptyList() + ), ) // 3) 한번에 저장 performanceRepository.saveAll(performanceList) - - // 이제 DB에 3개의 PerformanceEntity가 insert됨 } } \ No newline at end of file From 83a4eed3b29c66da77bb504586d041855c14a91f Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Thu, 9 Jan 2025 17:55:00 +0900 Subject: [PATCH 012/162] assign posterimageURL --- .../interpark/config/DataInitializer.kt | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index 5a2a2a1..27699e8 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -87,7 +87,7 @@ class DataInitializer( category = PerformanceCategory.MUSICAL, sales = 1000, dates = listOf(LocalDate.of(2024, 11, 29), LocalDate.of(2025, 5, 18)), - posterUrl = "http://example.com/poster/jekyll.jpg", + posterUrl = "https://ticketimage.interpark.com/Play/image/large/24/24013928_p.gif", backdropUrl = "http://example.com/backdrop/jekyll.jpg", seatIds = listOf("A1", "A2", "A3"), reviewIds = listOf("review1", "review2") @@ -99,7 +99,7 @@ class DataInitializer( category = PerformanceCategory.MUSICAL, sales = 2000, dates = listOf(LocalDate.of(2024, 12, 5), LocalDate.of(2025, 3, 2)), - posterUrl = "http://example.com/poster/phantom.jpg", + posterUrl = "https://ticketimage.interpark.com/Play/image/large/L0/L0000106_p.gif", backdropUrl = "http://example.com/backdrop/phantom.jpg", seatIds = listOf("B1", "B2", "B3"), reviewIds = listOf("review3", "review4") @@ -111,7 +111,7 @@ class DataInitializer( category = PerformanceCategory.MUSICAL, sales = 500, dates = listOf(LocalDate.of(2025, 1, 9), LocalDate.of(2025, 3, 9)), - posterUrl = "http://example.com/poster/mom.jpg", + posterUrl = "https://ticketimage.interpark.com/Play/image/large/24/24016737_p.gif", backdropUrl = "http://example.com/backdrop/mom.jpg", seatIds = listOf("C1", "C2", "C3"), reviewIds = listOf("review5") @@ -125,7 +125,7 @@ class DataInitializer( category = PerformanceCategory.CONCERT, sales = 0, dates = listOf(LocalDate.of(2025, 2, 1), LocalDate.of(2025, 2, 2)), - posterUrl = "http://example.com/poster/mom.jpg", + posterUrl = "https://ticketimage.interpark.com/Play/image/large/24/24018543_p.gif", backdropUrl = "http://example.com/backdrop/mom.jpg", seatIds = emptyList(), reviewIds = emptyList() @@ -137,7 +137,7 @@ class DataInitializer( category = PerformanceCategory.CONCERT, sales = 0, dates = listOf(LocalDate.of(2025, 2, 1), LocalDate.of(2025, 2, 2)), - posterUrl = "http://example.com/poster/mom.jpg", + posterUrl = "https://ticketimage.interpark.com/Play/image/large/25/25000084_p.gif", backdropUrl = "http://example.com/backdrop/mom.jpg", seatIds = emptyList(), reviewIds = emptyList() @@ -149,7 +149,7 @@ class DataInitializer( category = PerformanceCategory.CONCERT, sales = 0, dates = listOf(LocalDate.of(2025, 4, 16), LocalDate.of(2025, 4, 25)), - posterUrl = "http://example.com/poster/mom.jpg", + posterUrl = "https://ticketimage.interpark.com/Play/image/large/24/24013437_p.gif", backdropUrl = "http://example.com/backdrop/mom.jpg", seatIds = emptyList(), reviewIds = emptyList() @@ -163,7 +163,7 @@ class DataInitializer( category = PerformanceCategory.CLASSIC, sales = 0, dates = listOf(LocalDate.of(2025, 5, 11)), - posterUrl = "http://example.com/poster/mom.jpg", + posterUrl = "https://ticketimage.interpark.com/Play/image/large/24/24016119_p.gif", backdropUrl = "http://example.com/backdrop/mom.jpg", seatIds = emptyList(), reviewIds = emptyList() @@ -175,7 +175,7 @@ class DataInitializer( category = PerformanceCategory.CLASSIC, sales = 0, dates = listOf(LocalDate.of(2025, 5, 1)), - posterUrl = "http://example.com/poster/mom.jpg", + posterUrl = "https://ticketimage.interpark.com/Play/image/large/24/24015137_p.gif", backdropUrl = "http://example.com/backdrop/mom.jpg", seatIds = emptyList(), reviewIds = emptyList() @@ -187,7 +187,7 @@ class DataInitializer( category = PerformanceCategory.CLASSIC, sales = 0, dates = listOf(LocalDate.of(2025, 1, 11), LocalDate.of(2025, 1, 12)), - posterUrl = "http://example.com/poster/mom.jpg", + posterUrl = "https://ticketimage.interpark.com/Play/image/large/P0/P0004046_p.gif", backdropUrl = "http://example.com/backdrop/mom.jpg", seatIds = emptyList(), reviewIds = emptyList() @@ -201,7 +201,7 @@ class DataInitializer( category = PerformanceCategory.PLAY, sales = 0, dates = listOf(LocalDate.of(2025, 1, 28), LocalDate.of(2025, 3, 16)), - posterUrl = "http://example.com/poster/mom.jpg", + posterUrl = "https://ticketimage.interpark.com/Play/image/large/L0/L0000107_p.gif", backdropUrl = "http://example.com/backdrop/mom.jpg", seatIds = emptyList(), reviewIds = emptyList() @@ -213,7 +213,7 @@ class DataInitializer( category = PerformanceCategory.PLAY, sales = 0, dates = listOf(LocalDate.of(2025, 1, 28), LocalDate.of(2025, 3, 16)), - posterUrl = "http://example.com/poster/mom.jpg", + posterUrl = "https://ticketimage.interpark.com/Play/image/large/L0/L0000104_p.gif", backdropUrl = "http://example.com/backdrop/mom.jpg", seatIds = emptyList(), reviewIds = emptyList() @@ -225,7 +225,7 @@ class DataInitializer( category = PerformanceCategory.PLAY, sales = 0, dates = listOf(LocalDate.of(2025, 1, 7), LocalDate.of(2025, 3, 3)), - posterUrl = "http://example.com/poster/mom.jpg", + posterUrl = "https://ticketimage.interpark.com/Play/image/large/24/24017573_p.gif", backdropUrl = "http://example.com/backdrop/mom.jpg", seatIds = emptyList(), reviewIds = emptyList() From 7634b51448442a6f3093f9554d807d3f627ca777 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Thu, 9 Jan 2025 19:59:54 +0900 Subject: [PATCH 013/162] add: Entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 엔티티 작업중 --- .../interpark/seat/SeatException.kt | 18 +++++++++ .../interpark/seat/controller/Reservation.kt | 15 +++++++ .../interpark/seat/controller/Seat.kt | 19 +++++++++ .../seat/persistence/ReservationEntity.kt | 28 +++++++++++++ .../seat/persistence/ReservationRepository.kt | 7 ++++ .../interpark/seat/persistence/SeatEntity.kt | 24 ++++++++++++ .../seat/persistence/SeatRepository.kt | 14 +++++++ .../interpark/seat/service/SeatService.kt | 39 +++++++++++++++++++ 8 files changed, 164 insertions(+) create mode 100644 src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/seat/controller/Seat.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatRepository.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt new file mode 100644 index 0000000..31a20e2 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt @@ -0,0 +1,18 @@ +package com.wafflestudio.interpark.seat + +import com.wafflestudio.interpark.DomainException +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatusCode + +sealed class SeatException( + errorCode: Int, + httpStatusCode: HttpStatusCode, + msg: String, + cause: Throwable? = null, +) : DomainException(errorCode, httpStatusCode, msg, cause) + +class PerformanceNotFoundException : SeatException( + errorCode = 0, + httpStatusCode = HttpStatus.NOT_FOUND, + msg = "Performance not found", +) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt new file mode 100644 index 0000000..3ba28c9 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt @@ -0,0 +1,15 @@ +package com.wafflestudio.interpark.seat.controller + +import com.wafflestudio.interpark.seat.persistence.ReservationEntity + +data class Reservation( + val id: String, + val user: String, + val seatNumber: Int, +) { + companion object { + fun fromEntity(entity: ReservationEntity): Reservation { + + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Seat.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Seat.kt new file mode 100644 index 0000000..c1e9ccc --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Seat.kt @@ -0,0 +1,19 @@ +package com.wafflestudio.interpark.seat.controller + +import com.wafflestudio.interpark.seat.persistence.SeatEntity + +data class Seat( + val id: String, + val seatNumber: Pair, + val price: Int +) { + companion object { + fun fromEntity(entity: SeatEntity): Seat { + return Seat( + id = entity.id, + seatNumber = entity.seatNumber, + price = entity.price, + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt new file mode 100644 index 0000000..a593387 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt @@ -0,0 +1,28 @@ +package com.wafflestudio.interpark.seat.persistence + +import com.wafflestudio.interpark.user.persistence.UserEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.OneToOne +import jakarta.persistence.Table +import java.time.LocalDate + +@Entity +@Table(name = "reservations") +class ReservationEntity( + @Id + @GeneratedValue(strategy = GenerationType.UUID) + val id: String, + @OneToOne + @JoinColumn(name = "user_id", nullable = false) + val user: UserEntity, + @OneToOne + @JoinColumn(name = "seat_id", nullable = false) + val seat: SeatEntity, + @Column(name = "reservation_date") + val reservationDate: LocalDate, +) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt new file mode 100644 index 0000000..b407522 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt @@ -0,0 +1,7 @@ +package com.wafflestudio.interpark.seat.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface ReservationRepository : JpaRepository { + fun findByUserId(userId: String): List +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt new file mode 100644 index 0000000..bb640e2 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt @@ -0,0 +1,24 @@ +package com.wafflestudio.interpark.seat.persistence + +import com.wafflestudio.interpark.performance.persistence.PerformanceHallEntity +import jakarta.persistence.* +import java.time.LocalDate + +@Entity +@Table(name = "seats") +class SeatEntity( + @Id + @GeneratedValue(strategy = GenerationType.UUID) + val id: String, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "performance_hall_id", nullable = false) + val performanceHall: PerformanceHallEntity?, + @Column(name = "performance_date", nullable = false) + val performanceDate: LocalDate, + @Column(name = "seat_number", nullable = false) + val seatNumber: Pair, + @Column(name = "reserved", nullable = false) + var reserved: Boolean = false, + @Column(name = "price") + var price: Int, +) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatRepository.kt new file mode 100644 index 0000000..2be93ef --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatRepository.kt @@ -0,0 +1,14 @@ +package com.wafflestudio.interpark.seat.persistence + +import com.wafflestudio.interpark.performance.persistence.PerformanceHallEntity +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import java.time.LocalDate + +interface SeatRepository : JpaRepository { + @Query("SELECT s FROM SeatEntity s WHERE s.performanceHall = :performanceHall AND s.performanceDate = :performanceDate AND s.reserved = false") + fun findAvailableSeats( + performanceHall: PerformanceHallEntity, + performanceDate: LocalDate, + ): List +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt new file mode 100644 index 0000000..bae8e1a --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt @@ -0,0 +1,39 @@ +package com.wafflestudio.interpark.seat.service + +import com.wafflestudio.interpark.performance.persistence.PerformanceRepository +import com.wafflestudio.interpark.seat.PerformanceNotFoundException +import com.wafflestudio.interpark.seat.controller.Seat +import com.wafflestudio.interpark.seat.persistence.ReservationRepository +import com.wafflestudio.interpark.seat.persistence.SeatRepository +import com.wafflestudio.interpark.user.controller.User +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate + +@Service +class SeatService( + private val reservationRepository: ReservationRepository, + private val seatRepository: SeatRepository, + private val performanceRepository: PerformanceRepository, +) { + @Transactional + fun getAvailableSeats( + performanceId: String, + performanceDate: LocalDate, + ): List { + val targetPerformance = performanceRepository.findByIdOrNull(performanceId) ?: throw PerformanceNotFoundException() + val performanceHallId = targetPerformance.hall + val availableSeats: List = + seatRepository + .findAvailableSeats(performanceHall = performanceHallId, performanceDate = performanceDate) + .map {Seat.fromEntity(it)} + return availableSeats + } + + @Transactional + fun reserveSeat( + user: User, + seatId: String, + ): +} \ No newline at end of file From 5831bf04c2ddae19eb66f7b16e35b348550eaeb0 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Thu, 9 Jan 2025 22:04:19 +0900 Subject: [PATCH 014/162] assign posterimageURL --- .../controller/PerformanceController.kt | 17 +++-- .../user/controller/UserController.kt | 66 +++++++++++++++++++ 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index ee99ae2..73db404 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -2,9 +2,10 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import com.wafflestudio.interpark.performance.service.PerformanceService +import io.swagger.v3.oas.annotations.Operation import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RestController @RestController @@ -12,15 +13,23 @@ class PerformanceController( private val performanceService: PerformanceService, ) { @GetMapping("v1/performance/search") + @Operation( + summary = "공연 조회", + description = "제목과 카테고리에 해당하는 공연들의 리스트를 반환합니다." + ) fun searchPerformance( - @RequestParam title: String?, - @RequestParam category: PerformanceCategory?, + @RequestBody request: SearchPerformanceRequest ): ResponseEntity { - val queriedPerformances = performanceService.searchPerformance(title, category) + val queriedPerformances = performanceService.searchPerformance(request.title, request.category) return ResponseEntity.ok(SearchPerformanceResponse(performances = queriedPerformances)) } } +data class SearchPerformanceRequest( + val title: String?, + val category: PerformanceCategory?, +) + data class SearchPerformanceResponse( val performances: List ) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt index 5062a08..5bc36d5 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt @@ -2,6 +2,10 @@ package com.wafflestudio.interpark.user.controller import com.wafflestudio.interpark.user.* import com.wafflestudio.interpark.user.service.UserService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.media.Schema import jakarta.servlet.http.Cookie import jakarta.servlet.http.HttpServletResponse import org.springframework.http.ResponseEntity @@ -12,10 +16,72 @@ class UserController( private val userService: UserService, ) { @GetMapping("/api/v1/ping") + @Operation( + summary = "핑퐁 테스트", + description = "\"ping\"을 보내면 \"pong\"을 반환합니다." + ) fun ping() : ResponseEntity> { return ResponseEntity.ok(mapOf("message" to "pong")) } @PostMapping("/api/v1/signup") + @Operation( + summary = "사용자 회원가입", + description = """ + 새로운 사용자를 등록합니다. + 사용자 이름, 비밀번호, 닉네임, 이메일, 전화번호를 입력받아 저장합니다. + 요청이 유효하지 않은 경우 또는 사용자 이름이 중복된 경우 적절한 에러 메시지를 반환합니다. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "회원가입 성공", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = SignUpResponse::class) + )] + ), + ApiResponse( + responseCode = "400", + description = "잘못된 요청 (사용자 이름 또는 비밀번호가 유효하지 않음)", + content = [Content( + mediaType = "application/json", + schema = Schema( + type = "object", + example = """ + { + "error": "비밀번호는 8~12자여야 합니다.", + "errorCode": "INVALID_PASSWORD" + } + """ + ) + )] + ), + ApiResponse( + responseCode = "409", + description = "중복된 사용자 이름 (사용자 이름이 이미 존재함)", + content = [Content( + mediaType = "application/json", + schema = Schema( + type = "object", + example = """ + { + "error": "사용자 이름이 이미 존재합니다.", + "errorCode": "USERNAME_CONFLICT" + } + """ + ) + )] + ) + ], + requestBody = io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "회원가입 요청 데이터", + required = true, + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = SignUpRequest::class) + )] + ) + ) fun signup( @RequestBody request: SignUpRequest, ): ResponseEntity { From a8a06476b7f020e652f4bdd7df23f2dfc87f6d61 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Thu, 9 Jan 2025 23:23:48 +0900 Subject: [PATCH 015/162] add: SeatService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 임시저장하겠습니다 --- .../interpark/seat/SeatException.kt | 10 ++++++++-- .../interpark/seat/controller/Seat.kt | 2 +- .../seat/persistence/ReservationEntity.kt | 15 +++++++++++---- .../seat/persistence/ReservationRepository.kt | 2 ++ .../interpark/seat/persistence/SeatEntity.kt | 6 +----- .../seat/persistence/SeatRepository.kt | 5 ----- .../interpark/seat/service/SeatService.kt | 17 ++++++++--------- 7 files changed, 31 insertions(+), 26 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt index 31a20e2..433f512 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt @@ -11,8 +11,14 @@ sealed class SeatException( cause: Throwable? = null, ) : DomainException(errorCode, httpStatusCode, msg, cause) -class PerformanceNotFoundException : SeatException( +class PerformanceEventNotFoundException : SeatException( errorCode = 0, httpStatusCode = HttpStatus.NOT_FOUND, - msg = "Performance not found", + msg = "Performance Event not found", +) + +class ConflictingHallException : SeatException( + errorCode = 0, + httpStatusCode = HttpStatus.CONFLICT, + msg = "Halls found or no Hall found", ) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Seat.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Seat.kt index c1e9ccc..0fa9013 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Seat.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Seat.kt @@ -4,7 +4,7 @@ import com.wafflestudio.interpark.seat.persistence.SeatEntity data class Seat( val id: String, - val seatNumber: Pair, + val seatNumber: Pair, val price: Int ) { companion object { diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt index a593387..1e40b89 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt @@ -3,10 +3,12 @@ package com.wafflestudio.interpark.seat.persistence import com.wafflestudio.interpark.user.persistence.UserEntity import jakarta.persistence.Column import jakarta.persistence.Entity +import jakarta.persistence.FetchType import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne import jakarta.persistence.OneToOne import jakarta.persistence.Table import java.time.LocalDate @@ -17,12 +19,17 @@ class ReservationEntity( @Id @GeneratedValue(strategy = GenerationType.UUID) val id: String, - @OneToOne - @JoinColumn(name = "user_id", nullable = false) - val user: UserEntity, - @OneToOne + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + val user: UserEntity?, + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "seat_id", nullable = false) val seat: SeatEntity, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "performance_event_id") + val performanceEvent: PerformanceEventEntity, @Column(name = "reservation_date") val reservationDate: LocalDate, + @Column(name = "reserved") + val reserved: Boolean = false, ) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt index b407522..2cbb135 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt @@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository interface ReservationRepository : JpaRepository { fun findByUserId(userId: String): List + + fun findByPerformanceEventAndReservedIsFalse(performanceEvent: PerformanceEvent): List } \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt index bb640e2..f1d1b78 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt @@ -13,12 +13,8 @@ class SeatEntity( @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "performance_hall_id", nullable = false) val performanceHall: PerformanceHallEntity?, - @Column(name = "performance_date", nullable = false) - val performanceDate: LocalDate, @Column(name = "seat_number", nullable = false) - val seatNumber: Pair, - @Column(name = "reserved", nullable = false) - var reserved: Boolean = false, + val seatNumber: Pair, @Column(name = "price") var price: Int, ) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatRepository.kt index 2be93ef..490372b 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatRepository.kt @@ -6,9 +6,4 @@ import org.springframework.data.jpa.repository.Query import java.time.LocalDate interface SeatRepository : JpaRepository { - @Query("SELECT s FROM SeatEntity s WHERE s.performanceHall = :performanceHall AND s.performanceDate = :performanceDate AND s.reserved = false") - fun findAvailableSeats( - performanceHall: PerformanceHallEntity, - performanceDate: LocalDate, - ): List } \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt index bae8e1a..1c795a9 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt @@ -1,7 +1,8 @@ package com.wafflestudio.interpark.seat.service import com.wafflestudio.interpark.performance.persistence.PerformanceRepository -import com.wafflestudio.interpark.seat.PerformanceNotFoundException +import com.wafflestudio.interpark.seat.MultipleHallException +import com.wafflestudio.interpark.seat.PerformanceEventNotFoundException import com.wafflestudio.interpark.seat.controller.Seat import com.wafflestudio.interpark.seat.persistence.ReservationRepository import com.wafflestudio.interpark.seat.persistence.SeatRepository @@ -16,18 +17,16 @@ class SeatService( private val reservationRepository: ReservationRepository, private val seatRepository: SeatRepository, private val performanceRepository: PerformanceRepository, + private val performanceEventRepository: PerformanceEventRepository ) { @Transactional fun getAvailableSeats( - performanceId: String, - performanceDate: LocalDate, + performanceEventId: String, ): List { - val targetPerformance = performanceRepository.findByIdOrNull(performanceId) ?: throw PerformanceNotFoundException() - val performanceHallId = targetPerformance.hall - val availableSeats: List = - seatRepository - .findAvailableSeats(performanceHall = performanceHallId, performanceDate = performanceDate) - .map {Seat.fromEntity(it)} + val targetPerformanceEvent = performanceEventRepository.findById(performanceEventId) ?: throw PerformanceEventNotFoundException() + val availableSeats = reservationRepository.findByPerformanceEvent(targetPerformanceEvent) + . + return availableSeats } From e8650f71753626ced7bc9f9df7b5738b0324285c Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Fri, 10 Jan 2025 00:35:23 +0900 Subject: [PATCH 016/162] feat: SeatService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 좌석 확인, 예매, 예매 취소 기능 구현 아직 동시성 처리 안됐습니다 --- .../interpark/seat/SeatException.kt | 19 ++++-- .../interpark/seat/controller/Reservation.kt | 30 +++++++-- .../seat/persistence/ReservationEntity.kt | 7 ++- .../seat/persistence/ReservationRepository.kt | 2 +- .../interpark/seat/persistence/SeatEntity.kt | 4 +- .../interpark/seat/service/SeatService.kt | 63 ++++++++++++++++--- 6 files changed, 101 insertions(+), 24 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt index 433f512..37ea7ff 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt @@ -11,14 +11,25 @@ sealed class SeatException( cause: Throwable? = null, ) : DomainException(errorCode, httpStatusCode, msg, cause) -class PerformanceEventNotFoundException : SeatException( +class ReservationNotFoundException : SeatException( errorCode = 0, httpStatusCode = HttpStatus.NOT_FOUND, - msg = "Performance Event not found", + msg = "Reservation not found", ) -class ConflictingHallException : SeatException( +class ReservedAlreadyException : SeatException( errorCode = 0, httpStatusCode = HttpStatus.CONFLICT, - msg = "Halls found or no Hall found", + msg = "Reserved already exists", +) + +class ReservedYetException : SeatException( + errorCode = 0, + httpStatusCode = HttpStatus.CONFLICT, + msg = "Not Reserved Yet", +) +class ReservationPermissionDeniedException : SeatException( + errorCode = 0, + httpStatusCode = HttpStatus.FORBIDDEN, + msg = "Reservation Permission denied", ) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt index 3ba28c9..54ed80f 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt @@ -1,15 +1,37 @@ package com.wafflestudio.interpark.seat.controller +import com.wafflestudio.interpark.performance.persistence.PerformanceEntity +import com.wafflestudio.interpark.performance.persistence.PerformanceEventEntity +import com.wafflestudio.interpark.performance.persistence.PerformanceHallEntity import com.wafflestudio.interpark.seat.persistence.ReservationEntity +import com.wafflestudio.interpark.seat.persistence.SeatEntity +import java.time.Instant +import java.time.LocalDate data class Reservation( val id: String, - val user: String, - val seatNumber: Int, + val performanceTitle: String, + val performanceHallName: String, + val seat: Seat, + val performanceStartAt: Instant, + val performanceEndAt: Instant, + val reservationDate: LocalDate, ) { companion object { - fun fromEntity(entity: ReservationEntity): Reservation { - + fun fromEntity(reservationEntity: ReservationEntity, + performanceEntity: PerformanceEntity, + performanceHallEntity: PerformanceHallEntity, + seatEntity: SeatEntity, + performanceEventEntity: PerformanceEventEntity): Reservation { + return Reservation( + id = reservationEntity.id, + performanceTitle = performanceEntity.title, + performanceHallName = performanceHallEntity.name, + seat = Seat.fromEntity(seatEntity), + performanceStartAt = performanceEventEntity.startAt, + performanceEndAt = performanceEventEntity.endAt, + reservationDate = reservationEntity.reservationDate!!, + ) } } } \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt index 1e40b89..65796d3 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt @@ -1,5 +1,6 @@ package com.wafflestudio.interpark.seat.persistence +import com.wafflestudio.interpark.performance.persistence.PerformanceEventEntity import com.wafflestudio.interpark.user.persistence.UserEntity import jakarta.persistence.Column import jakarta.persistence.Entity @@ -21,7 +22,7 @@ class ReservationEntity( val id: String, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") - val user: UserEntity?, + var user: UserEntity?, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "seat_id", nullable = false) val seat: SeatEntity, @@ -29,7 +30,7 @@ class ReservationEntity( @JoinColumn(name = "performance_event_id") val performanceEvent: PerformanceEventEntity, @Column(name = "reservation_date") - val reservationDate: LocalDate, + var reservationDate: LocalDate?, @Column(name = "reserved") - val reserved: Boolean = false, + var reserved: Boolean = false, ) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt index 2cbb135..268056e 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt @@ -5,5 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository interface ReservationRepository : JpaRepository { fun findByUserId(userId: String): List - fun findByPerformanceEventAndReservedIsFalse(performanceEvent: PerformanceEvent): List + fun findByPerformanceEventIdAndReservedIsFalse(performanceEventId: String): List } \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt index f1d1b78..73006ab 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt @@ -12,9 +12,9 @@ class SeatEntity( val id: String, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "performance_hall_id", nullable = false) - val performanceHall: PerformanceHallEntity?, + val performanceHall: PerformanceHallEntity, @Column(name = "seat_number", nullable = false) val seatNumber: Pair, @Column(name = "price") - var price: Int, + var price: Int = 10000, ) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt index 1c795a9..3441f92 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt @@ -1,12 +1,18 @@ package com.wafflestudio.interpark.seat.service +import com.wafflestudio.interpark.performance.persistence.PerformanceEventRepository import com.wafflestudio.interpark.performance.persistence.PerformanceRepository -import com.wafflestudio.interpark.seat.MultipleHallException -import com.wafflestudio.interpark.seat.PerformanceEventNotFoundException +import com.wafflestudio.interpark.seat.ReservationNotFoundException +import com.wafflestudio.interpark.seat.ReservationPermissionDeniedException +import com.wafflestudio.interpark.seat.ReservedAlreadyException +import com.wafflestudio.interpark.seat.ReservedYetException +import com.wafflestudio.interpark.seat.controller.Reservation import com.wafflestudio.interpark.seat.controller.Seat import com.wafflestudio.interpark.seat.persistence.ReservationRepository import com.wafflestudio.interpark.seat.persistence.SeatRepository +import com.wafflestudio.interpark.user.AuthenticateException import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.persistence.UserRepository import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -17,22 +23,59 @@ class SeatService( private val reservationRepository: ReservationRepository, private val seatRepository: SeatRepository, private val performanceRepository: PerformanceRepository, - private val performanceEventRepository: PerformanceEventRepository + private val performanceEventRepository: PerformanceEventRepository, + private val userRepository: UserRepository, ) { @Transactional fun getAvailableSeats( performanceEventId: String, - ): List { - val targetPerformanceEvent = performanceEventRepository.findById(performanceEventId) ?: throw PerformanceEventNotFoundException() - val availableSeats = reservationRepository.findByPerformanceEvent(targetPerformanceEvent) - . - + ): List> { + val availableReservations = reservationRepository.findByPerformanceEventIdAndReservedIsFalse(performanceEventId) + val availableSeats = availableReservations.map { Pair(it.id, Seat.fromEntity(it.seat)) } return availableSeats } @Transactional fun reserveSeat( user: User, - seatId: String, - ): + reservationId: String, + ): Reservation { + val targetUser = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() + val targetReservation = reservationRepository.findByIdOrNull(reservationId) ?: throw ReservationNotFoundException() + val targetSeat = targetReservation.seat + val targetPerformanceHall = targetSeat.performanceHall + val targetPerformanceEvent = targetReservation.performanceEvent + val targetPerformance = targetPerformanceEvent.performance + + if(targetReservation.reserved) throw ReservedAlreadyException() + targetReservation.user = targetUser + targetReservation.reserved = true + targetReservation.reservationDate = LocalDate.now() + + val reservation = Reservation.fromEntity( + reservationEntity = targetReservation, + performanceEntity = targetPerformance, + performanceHallEntity = targetPerformanceHall, + seatEntity = targetSeat, + performanceEventEntity = targetPerformanceEvent, + ) + return reservation + } + + @Transactional + fun cancelReserveSeat( + user: User, + reservationId: String, + ) { + val reservationEntity = reservationRepository.findByIdOrNull(reservationId) ?: throw ReservationNotFoundException() + val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() + val reservationUser = reservationEntity.user ?: throw ReservedYetException() + if (reservationUser.id != userEntity.id) { + throw ReservationPermissionDeniedException() + } + + reservationEntity.user = null + reservationEntity.reservationDate = null + reservationEntity.reserved = false + } } \ No newline at end of file From 2600422713297fab12f4d9b54ec2e4c4350988b4 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Fri, 10 Jan 2025 00:59:30 +0900 Subject: [PATCH 017/162] feat: Add Controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 컨트롤러 추가했습니다 곧 테스트 코드 추가해보겠습니다 --- .../seat/controller/SeatController.kt | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt new file mode 100644 index 0000000..ff5c3f7 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt @@ -0,0 +1,59 @@ +package com.wafflestudio.interpark.seat.controller + +import com.wafflestudio.interpark.seat.service.SeatService +import com.wafflestudio.interpark.user.AuthUser +import com.wafflestudio.interpark.user.controller.User +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +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.RestController + +@RestController +class SeatController( + private val seatService: SeatService, +) { + @GetMapping("/api/v1/seat/{performanceEventId}/available") + fun getAvailableSeats( + @PathVariable performanceEventId: String, + ): ResponseEntity { + val seats = seatService.getAvailableSeats(performanceEventId) + return ResponseEntity.ok(GetAvailableSeatsResponse(seats.map{ AvailableSeat(it.first, it.second) })) + } + + @PostMapping("/api/v1/reservation/reserve") + fun reserveSeat( + @RequestBody request: ReserveSeatRequest, + @AuthUser user: User, + ): ResponseEntity { + val reservation = seatService.reserveSeat(user, request.reservationId) + return ResponseEntity.status(201).body(ReserveSeatResponse(reservation)) + } + + @PostMapping("/api/v1/reservation/cancel") + fun cancelReserveSeat( + @RequestBody request: CancelReserveSeatRequest, + @AuthUser user: User, + ): ResponseEntity { + seatService.cancelReserveSeat(user, request.reservationId) + return ResponseEntity.noContent().build() + } +} + +data class AvailableSeat( + val reservationId: String, + val seat: Seat, +) +data class GetAvailableSeatsResponse( + val availableSeats: List +) +data class ReserveSeatRequest( + val reservationId: String, +) +data class ReserveSeatResponse( + val reservation: Reservation, +) +data class CancelReserveSeatRequest( + val reservationId: String, +) \ No newline at end of file From 2685d1d05d5dcdcd08b4d6cd479e882bae0eb47f Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Fri, 10 Jan 2025 01:46:59 +0900 Subject: [PATCH 018/162] =?UTF-8?q?modify=20searchPerformance,=20GET?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=9D=80=20RequestBody=EB=A5=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=A0=20=EC=88=98=20=EC=97=86=EB=8B=A4=EA=B3=A0=20?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EB=8B=A4=EC=8B=9C=20RequestParam=EC=9D=84?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8A=94=20=EC=9B=90=EB=9E=98=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../performance/controller/PerformanceController.kt | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index 95c5914..34bc0d9 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -18,9 +18,10 @@ class PerformanceController( description = "제목과 카테고리에 해당하는 공연들의 리스트를 반환합니다." ) fun searchPerformance( - @RequestBody request: SearchPerformanceRequest + @RequestParam title: String?, + @RequestParam category: PerformanceCategory?, ): ResponseEntity { - val queriedPerformances = performanceService.searchPerformance(request.title, request.category) + val queriedPerformances = performanceService.searchPerformance(title, category) return ResponseEntity.ok(queriedPerformances) } @@ -52,11 +53,6 @@ class PerformanceController( } -data class SearchPerformanceRequest( - val title: String?, - val category: PerformanceCategory?, -) - typealias SearchPerformanceResponse = List data class CreatePerformanceRequest( From 4bc0d87b86e58006b243872dcb815e5ebeefae56 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Fri, 10 Jan 2025 14:13:11 +0900 Subject: [PATCH 019/162] feat: SeatCreation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit performanceHallId와 type을 받아 좌석엔티티들을 자동으로 생성하는 서비스 함수와 performanceEventId를 받아 빈 예약 가능 엔티티들을 자동을 생성하는 서비스를 만들었습니다 추후 createPerformanceHall과 createPerformanceEvent함수를 수정해서 과정을 더 자동화할 수 있을 것 같습니다 --- .../interpark/seat/SeatException.kt | 6 ++ .../interpark/seat/controller/Seat.kt | 2 +- .../seat/persistence/ReservationEntity.kt | 4 +- .../interpark/seat/persistence/SeatEntity.kt | 4 +- .../seat/persistence/SeatRepository.kt | 1 + .../seat/service/SeatCreationService.kt | 67 +++++++++++++++++++ .../interpark/seat/service/SeatService.kt | 9 ++- 7 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatCreationService.kt diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt index 37ea7ff..700f715 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt @@ -32,4 +32,10 @@ class ReservationPermissionDeniedException : SeatException( errorCode = 0, httpStatusCode = HttpStatus.FORBIDDEN, msg = "Reservation Permission denied", +) + +class InValidHallTypeException : SeatException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "Invalid Hall Type", ) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Seat.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Seat.kt index 0fa9013..c1e9ccc 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Seat.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Seat.kt @@ -4,7 +4,7 @@ import com.wafflestudio.interpark.seat.persistence.SeatEntity data class Seat( val id: String, - val seatNumber: Pair, + val seatNumber: Pair, val price: Int ) { companion object { diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt index 65796d3..bd47ea0 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt @@ -19,10 +19,10 @@ import java.time.LocalDate class ReservationEntity( @Id @GeneratedValue(strategy = GenerationType.UUID) - val id: String, + val id: String? = null, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") - var user: UserEntity?, + var user: UserEntity? = null, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "seat_id", nullable = false) val seat: SeatEntity, diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt index 73006ab..99488b4 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt @@ -9,12 +9,12 @@ import java.time.LocalDate class SeatEntity( @Id @GeneratedValue(strategy = GenerationType.UUID) - val id: String, + val id: String? = null, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "performance_hall_id", nullable = false) val performanceHall: PerformanceHallEntity, @Column(name = "seat_number", nullable = false) - val seatNumber: Pair, + val seatNumber: Pair, @Column(name = "price") var price: Int = 10000, ) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatRepository.kt index 490372b..fde4e27 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatRepository.kt @@ -6,4 +6,5 @@ import org.springframework.data.jpa.repository.Query import java.time.LocalDate interface SeatRepository : JpaRepository { + fun findByPerformanceHall(performanceHall: PerformanceHallEntity): List } \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatCreationService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatCreationService.kt new file mode 100644 index 0000000..b95dcb6 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatCreationService.kt @@ -0,0 +1,67 @@ +package com.wafflestudio.interpark.seat.service + +import com.wafflestudio.interpark.performance.PerformanceEventNotFoundException +import com.wafflestudio.interpark.performance.PerformanceHallNotFoundException +import com.wafflestudio.interpark.performance.persistence.PerformanceEventRepository +import com.wafflestudio.interpark.performance.persistence.PerformanceHallRepository +import com.wafflestudio.interpark.seat.InValidHallTypeException +import com.wafflestudio.interpark.seat.persistence.ReservationEntity +import com.wafflestudio.interpark.seat.persistence.ReservationRepository +import com.wafflestudio.interpark.seat.persistence.SeatEntity +import com.wafflestudio.interpark.seat.persistence.SeatRepository +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate + +@Service +class SeatCreationService( + private val seatRepository: SeatRepository, + private val reservationRepository: ReservationRepository, + private val performanceHallRepository: PerformanceHallRepository, + private val performanceEventRepository: PerformanceEventRepository, +) { + @Transactional + fun createSeats( + performanceHallId: String, + type: String? + ) { + val performanceHallEntity = performanceHallRepository.findByIdOrNull(performanceHallId) ?: throw PerformanceHallNotFoundException() + when (type) { + "DEFAULT" -> { + // 10행 10열의 공연장 + // TODO: 병렬처리하기 + for(row in 1..10) { + for (col in 1..10) { + seatRepository.save( + SeatEntity( + performanceHall = performanceHallEntity, + seatNumber = Pair(row, col), + price = 10000 + ) + ) + } + } + } + else -> { + throw InValidHallTypeException() + } + } + } + + @Transactional + fun createEmptyReservations( + performanceEventId: String, + ) { + val performanceEventEntity = performanceEventRepository.findByIdOrNull(performanceEventId) ?: throw PerformanceEventNotFoundException() + val seats = seatRepository.findByPerformanceHall(performanceEventEntity.performanceHall) + val emptyReservations = seats.map { + ReservationEntity( + seat = it, + performanceEvent = performanceEventEntity, + reservationDate = null, + ) + } + reservationRepository.saveAll(emptyReservations) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt index 3441f92..3a95c05 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt @@ -1,7 +1,9 @@ package com.wafflestudio.interpark.seat.service +import com.wafflestudio.interpark.performance.controller.PerformanceHall import com.wafflestudio.interpark.performance.persistence.PerformanceEventRepository import com.wafflestudio.interpark.performance.persistence.PerformanceRepository +import com.wafflestudio.interpark.seat.InValidHallTypeException import com.wafflestudio.interpark.seat.ReservationNotFoundException import com.wafflestudio.interpark.seat.ReservationPermissionDeniedException import com.wafflestudio.interpark.seat.ReservedAlreadyException @@ -9,6 +11,7 @@ import com.wafflestudio.interpark.seat.ReservedYetException import com.wafflestudio.interpark.seat.controller.Reservation import com.wafflestudio.interpark.seat.controller.Seat import com.wafflestudio.interpark.seat.persistence.ReservationRepository +import com.wafflestudio.interpark.seat.persistence.SeatEntity import com.wafflestudio.interpark.seat.persistence.SeatRepository import com.wafflestudio.interpark.user.AuthenticateException import com.wafflestudio.interpark.user.controller.User @@ -17,13 +20,12 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDate +import java.util.concurrent.Executors @Service class SeatService( private val reservationRepository: ReservationRepository, private val seatRepository: SeatRepository, - private val performanceRepository: PerformanceRepository, - private val performanceEventRepository: PerformanceEventRepository, private val userRepository: UserRepository, ) { @Transactional @@ -31,7 +33,7 @@ class SeatService( performanceEventId: String, ): List> { val availableReservations = reservationRepository.findByPerformanceEventIdAndReservedIsFalse(performanceEventId) - val availableSeats = availableReservations.map { Pair(it.id, Seat.fromEntity(it.seat)) } + val availableSeats = availableReservations.map { Pair(it.id!!, Seat.fromEntity(it.seat)) } return availableSeats } @@ -40,6 +42,7 @@ class SeatService( user: User, reservationId: String, ): Reservation { + //TODO: 동시성 처리하기 val targetUser = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() val targetReservation = reservationRepository.findByIdOrNull(reservationId) ?: throw ReservationNotFoundException() val targetSeat = targetReservation.seat From 5d9558e144c16115f0402e062722e4b95b7a47e2 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Fri, 10 Jan 2025 19:48:55 +0900 Subject: [PATCH 020/162] add: SeatIntegrationTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SeatIntegrationTest 만들었습니다 테스트 전부 통과하며 빌드 성공합니다 다만 테스트에 mysql 데이터베이스가 쓰이며 초기화가 자동으로 되지 않아 문제가 생기니 이는 개선이 필요합니다 --- .../interpark/seat/controller/Reservation.kt | 2 +- .../seat/controller/SeatController.kt | 2 +- .../interpark/seat/service/SeatService.kt | 2 +- .../interpark/SeatIntegrationTest.kt | 300 ++++++++++++++++++ src/test/resources/UserApi.http | 4 + 5 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt index 54ed80f..5a65b69 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt @@ -24,7 +24,7 @@ data class Reservation( seatEntity: SeatEntity, performanceEventEntity: PerformanceEventEntity): Reservation { return Reservation( - id = reservationEntity.id, + id = reservationEntity.id!!, performanceTitle = performanceEntity.title, performanceHallName = performanceHallEntity.name, seat = Seat.fromEntity(seatEntity), diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt index ff5c3f7..910a018 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt @@ -28,7 +28,7 @@ class SeatController( @AuthUser user: User, ): ResponseEntity { val reservation = seatService.reserveSeat(user, request.reservationId) - return ResponseEntity.status(201).body(ReserveSeatResponse(reservation)) + return ResponseEntity.status(200).body(ReserveSeatResponse(reservation)) } @PostMapping("/api/v1/reservation/cancel") diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt index 3a95c05..d8be181 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt @@ -74,7 +74,7 @@ class SeatService( val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() val reservationUser = reservationEntity.user ?: throw ReservedYetException() if (reservationUser.id != userEntity.id) { - throw ReservationPermissionDeniedException() + throw AuthenticateException() } reservationEntity.user = null diff --git a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt new file mode 100644 index 0000000..67db82b --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt @@ -0,0 +1,300 @@ +package com.wafflestudio.interpark + +import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.interpark.seat.service.SeatCreationService +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import java.time.Instant +import java.util.UUID + +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class SeatIntegrationTest +@Autowired +constructor( + private val mvc: MockMvc, + private val mapper: ObjectMapper, + private val seatCreationService: SeatCreationService, +) { + private lateinit var accessToken: String + private lateinit var performanceHallId: String + private lateinit var performanceId: String + private lateinit var performanceEventId: String + @BeforeEach + fun setup() { + val username = UUID.randomUUID().toString().take(8) + val password = "password123" + + // 1️⃣ 회원가입 + mvc.perform( + post("/api/v1/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + "nickname" to "reviewer", + "phoneNumber" to "010-0000-0000", + "email" to "reviewer@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + // 2️⃣ 로그인 → 토큰 획득 + accessToken = + mvc.perform( + post("/api/v1/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + //Seat와 Reservation 만들기 위한 EventId 만들기 + performanceHallId = + mvc.perform( + get("/api/v1/performance-hall") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performanceHalls = mapper.readTree(it) + performanceHalls[0].get("id").asText() + } + performanceId = + mvc.perform( + get("/v1/performance/search") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performances = mapper.readTree(it) + performances[0].get("id").asText() + } + + mvc.perform( + post("/admin/v1/performance-event") + .content( + mapper.writeValueAsString( + mapOf( + "performanceId" to performanceId, + "performanceHallId" to performanceHallId, + "startAt" to Instant.now(), + "endAt" to Instant.now(), + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON) + ) + + performanceEventId = + mvc.perform( + get("/api/v1/performance-event") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performanceEvents = mapper.readTree(it) + performanceEvents[0].get("id").asText() + } + //Seat와 Reservation만들기 + seatCreationService.createSeats(performanceHallId, "DEFAULT") + seatCreationService.createEmptyReservations(performanceEventId) + } + + @Test + fun `가능한 좌석들의 정보를 받을 수 있다`() { + mvc.perform( + get("/api/v1/seat/$performanceEventId/available") + ).andExpect(status().`is`(200)) + } + + @Test + fun `좌석을 예매할 수 있다`() { + val reservationId = mvc.perform( + get("/api/v1/seat/$performanceEventId/available") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val availableSeats = mapper.readTree(it).get("availableSeats") + availableSeats[0].get("reservationId").asText() + } + mvc.perform( + post("/api/v1/reservation/reserve") + .content( + mapper.writeValueAsString( + mapOf( + "reservationId" to reservationId, + ), + ), + ) + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + ).andExpect(status().`is`(200)) + //한번 예매된 좌석은 예매되지 않는다 + mvc.perform( + post("/api/v1/reservation/reserve") + .content( + mapper.writeValueAsString( + mapOf( + "reservationId" to reservationId, + ), + ), + ) + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + ).andExpect(status().`is`(409)) + } + + @Test + fun `좌석을 취소할 수 있다`() { + val reservationId = mvc.perform( + get("/api/v1/seat/$performanceEventId/available") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val availableSeats = mapper.readTree(it).get("availableSeats") + availableSeats[0].get("reservationId").asText() + } + mvc.perform( + post("/api/v1/reservation/reserve") + .content( + mapper.writeValueAsString( + mapOf( + "reservationId" to reservationId, + ), + ), + ) + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + ).andExpect(status().`is`(200)) + + mvc.perform( + post("/api/v1/reservation/cancel") + .content( + mapper.writeValueAsString( + mapOf( + "reservationId" to reservationId, + ), + ), + ) + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + ).andExpect(status().`is`(204)) + + //좌석을 취소하고 나면 다시 예매할 수 있다 + mvc.perform( + post("/api/v1/reservation/reserve") + .content( + mapper.writeValueAsString( + mapOf( + "reservationId" to reservationId, + ), + ), + ) + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + ).andExpect(status().`is`(200)) + } + + @Test + fun `다른 사람은 좌석을 취소할 수 없다`() { + val reservationId = mvc.perform( + get("/api/v1/seat/$performanceEventId/available") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val availableSeats = mapper.readTree(it).get("availableSeats") + availableSeats[0].get("reservationId").asText() + } + + mvc.perform( + post("/api/v1/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "correct99", + "password" to "12345678", + "nickname" to "examplename", + "phoneNumber" to "010-0000-0000", + "email" to "test@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ) + .andExpect(status().`is`(200)) + val otherAccessToken = + mvc.perform( + post("/api/v1/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "correct99", + "password" to "12345678", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + mvc.perform( + post("/api/v1/reservation/reserve") + .content( + mapper.writeValueAsString( + mapOf( + "reservationId" to reservationId, + ), + ), + ) + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + ).andExpect(status().`is`(200)) + + mvc.perform( + post("/api/v1/reservation/cancel") + .content( + mapper.writeValueAsString( + mapOf( + "reservationId" to reservationId, + ), + ), + ) + .header("Authorization", "Bearer $otherAccessToken") + .contentType(MediaType.APPLICATION_JSON) + ).andExpect(status().`is`(401)) + } +} \ No newline at end of file diff --git a/src/test/resources/UserApi.http b/src/test/resources/UserApi.http index f8a5475..e12f293 100644 --- a/src/test/resources/UserApi.http +++ b/src/test/resources/UserApi.http @@ -19,6 +19,10 @@ Content-Type: application/json "password": "12345678" } +### 로그아웃 +POST http://localhost:8080/api/v1/signout +Content-Type: application/json + ### pingpong GET http://localhost:8080/api/v1/ping Accept: application/json From fc68d229f50b2ea159821f682246e24a8804bf52 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Fri, 10 Jan 2025 20:42:26 +0900 Subject: [PATCH 021/162] chore: use h2 while test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit application.yaml 파일을 테스트 코드를 위해 하나 추가해줬습니다 테스트코드에서 @Transactional까지 사용한다면 테스트 코드가 서로 영향을 끼치지 않게 됩니다 --- Makefile | 3 +-- .../interpark/ReplyIntegrationTest.kt | 2 ++ .../interpark/ReviewIntegrationTest.kt | 2 ++ .../interpark/SeatIntegrationTest.kt | 6 ++++-- .../interpark/UserIntegrationTest.kt | 2 ++ src/test/resources/application.yaml | 20 +++++++++++++++++++ 6 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 src/test/resources/application.yaml diff --git a/Makefile b/Makefile index eb233b8..11d4b4d 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,4 @@ all: - docker compose up -d mysql - ./gradlew build + gradlew build docker build -t myapp:1.0 . docker compose up \ No newline at end of file diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt index 8d500fb..476c4da 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt @@ -10,10 +10,12 @@ import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.transaction.annotation.Transactional import java.util.UUID @AutoConfigureMockMvc @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Transactional class ReplyControllerTest @Autowired constructor( diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt index 049027d..7c86a32 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt @@ -11,10 +11,12 @@ import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* import org.springframework.test.web.servlet.result.MockMvcResultHandlers.* import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.transaction.annotation.Transactional import java.util.UUID @AutoConfigureMockMvc @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Transactional class ReviewIntegrationTest @Autowired constructor( diff --git a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt index 67db82b..3e6499c 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt @@ -11,11 +11,13 @@ import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.transaction.annotation.Transactional import java.time.Instant import java.util.UUID @AutoConfigureMockMvc @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Transactional class SeatIntegrationTest @Autowired constructor( @@ -242,7 +244,7 @@ constructor( .content( mapper.writeValueAsString( mapOf( - "username" to "correct99", + "username" to "correct2", "password" to "12345678", "nickname" to "examplename", "phoneNumber" to "010-0000-0000", @@ -259,7 +261,7 @@ constructor( .content( mapper.writeValueAsString( mapOf( - "username" to "correct99", + "username" to "correct2", "password" to "12345678", ), ), diff --git a/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt index bd99481..d1ded75 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt @@ -11,9 +11,11 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional @AutoConfigureMockMvc @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Transactional class UserIntegrationTest @Autowired constructor( diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml new file mode 100644 index 0000000..1c6362b --- /dev/null +++ b/src/test/resources/application.yaml @@ -0,0 +1,20 @@ +spring: + datasource: + url: 'jdbc:h2:mem:testdb;MODE=MySQL' + driver-class-name: org.h2.Driver + username: sa + password: 1234 + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: create + properties: + hibernate: + show_sql: true + format_sql: true + dialect: org.hibernate.dialect.H2Dialect + defer-datasource-initialization: true + h2: + console: + enabled: true + path: /h2-console \ No newline at end of file From 30757e6f756842bd59cd5c6816d13fe75afd0732 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sat, 11 Jan 2025 13:25:07 +0900 Subject: [PATCH 022/162] fix: Seat Reservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reservation 바뀐 정보 반영하도록 save 사용했고, Detail확인하는 영역은 컨트롤러까지 분리했습니다 --- .../interpark/seat/controller/Reservation.kt | 14 +++-- .../seat/controller/SeatController.kt | 33 ++++++++--- .../interpark/seat/service/SeatService.kt | 58 +++++++++++-------- 3 files changed, 67 insertions(+), 38 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt index 5a65b69..a6d2e81 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt @@ -18,11 +18,13 @@ data class Reservation( val reservationDate: LocalDate, ) { companion object { - fun fromEntity(reservationEntity: ReservationEntity, - performanceEntity: PerformanceEntity, - performanceHallEntity: PerformanceHallEntity, - seatEntity: SeatEntity, - performanceEventEntity: PerformanceEventEntity): Reservation { + fun fromEntity( + reservationEntity: ReservationEntity, + performanceEntity: PerformanceEntity, + performanceHallEntity: PerformanceHallEntity, + seatEntity: SeatEntity, + performanceEventEntity: PerformanceEventEntity, + ): Reservation { return Reservation( id = reservationEntity.id!!, performanceTitle = performanceEntity.title, @@ -34,4 +36,4 @@ data class Reservation( ) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt index 910a018..0f1d8d0 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt @@ -19,7 +19,7 @@ class SeatController( @PathVariable performanceEventId: String, ): ResponseEntity { val seats = seatService.getAvailableSeats(performanceEventId) - return ResponseEntity.ok(GetAvailableSeatsResponse(seats.map{ AvailableSeat(it.first, it.second) })) + return ResponseEntity.ok(GetAvailableSeatsResponse(seats.map { AvailableSeat(it.first, it.second) })) } @PostMapping("/api/v1/reservation/reserve") @@ -27,16 +27,25 @@ class SeatController( @RequestBody request: ReserveSeatRequest, @AuthUser user: User, ): ResponseEntity { - val reservation = seatService.reserveSeat(user, request.reservationId) - return ResponseEntity.status(200).body(ReserveSeatResponse(reservation)) + val reservationId = seatService.reserveSeat(user, request.reservationId) + return ResponseEntity.status(200).body(ReserveSeatResponse(reservationId)) + } + + @GetMapping("/api/v1/reservation/detail/{reservationId}") + fun getReservedSeatDetail( + @PathVariable reservationId: String, + @AuthUser user: User, + ): ResponseEntity { + val reservationDetail = seatService.getReservedSeatDetail(user, reservationId) + return ResponseEntity.status(200).body(GetReservedSeatDetailResponse(reservationDetail)) } @PostMapping("/api/v1/reservation/cancel") - fun cancelReserveSeat( + fun cancelReservedSeat( @RequestBody request: CancelReserveSeatRequest, @AuthUser user: User, ): ResponseEntity { - seatService.cancelReserveSeat(user, request.reservationId) + seatService.cancelReservedSeat(user, request.reservationId) return ResponseEntity.noContent().build() } } @@ -45,15 +54,23 @@ data class AvailableSeat( val reservationId: String, val seat: Seat, ) + data class GetAvailableSeatsResponse( - val availableSeats: List + val availableSeats: List, ) + data class ReserveSeatRequest( val reservationId: String, ) + data class ReserveSeatResponse( - val reservation: Reservation, + val reservationId: String, +) + +data class GetReservedSeatDetailResponse( + val reservedSeat: Reservation ) + data class CancelReserveSeatRequest( val reservationId: String, -) \ No newline at end of file +) diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt index d8be181..fe896dd 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt @@ -1,17 +1,11 @@ package com.wafflestudio.interpark.seat.service -import com.wafflestudio.interpark.performance.controller.PerformanceHall -import com.wafflestudio.interpark.performance.persistence.PerformanceEventRepository -import com.wafflestudio.interpark.performance.persistence.PerformanceRepository -import com.wafflestudio.interpark.seat.InValidHallTypeException import com.wafflestudio.interpark.seat.ReservationNotFoundException -import com.wafflestudio.interpark.seat.ReservationPermissionDeniedException import com.wafflestudio.interpark.seat.ReservedAlreadyException import com.wafflestudio.interpark.seat.ReservedYetException import com.wafflestudio.interpark.seat.controller.Reservation import com.wafflestudio.interpark.seat.controller.Seat import com.wafflestudio.interpark.seat.persistence.ReservationRepository -import com.wafflestudio.interpark.seat.persistence.SeatEntity import com.wafflestudio.interpark.seat.persistence.SeatRepository import com.wafflestudio.interpark.user.AuthenticateException import com.wafflestudio.interpark.user.controller.User @@ -20,7 +14,6 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDate -import java.util.concurrent.Executors @Service class SeatService( @@ -29,9 +22,7 @@ class SeatService( private val userRepository: UserRepository, ) { @Transactional - fun getAvailableSeats( - performanceEventId: String, - ): List> { + fun getAvailableSeats(performanceEventId: String): List> { val availableReservations = reservationRepository.findByPerformanceEventIdAndReservedIsFalse(performanceEventId) val availableSeats = availableReservations.map { Pair(it.id!!, Seat.fromEntity(it.seat)) } return availableSeats @@ -41,32 +32,51 @@ class SeatService( fun reserveSeat( user: User, reservationId: String, - ): Reservation { - //TODO: 동시성 처리하기 + ): String { + // TODO: 동시성 처리하기 val targetUser = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() val targetReservation = reservationRepository.findByIdOrNull(reservationId) ?: throw ReservationNotFoundException() - val targetSeat = targetReservation.seat - val targetPerformanceHall = targetSeat.performanceHall - val targetPerformanceEvent = targetReservation.performanceEvent - val targetPerformance = targetPerformanceEvent.performance - if(targetReservation.reserved) throw ReservedAlreadyException() + if (targetReservation.reserved) throw ReservedAlreadyException() + targetReservation.user = targetUser targetReservation.reserved = true targetReservation.reservationDate = LocalDate.now() + reservationRepository.save(targetReservation) + + return reservationId + } + + @Transactional + fun getReservedSeatDetail( + user: User, + reservationId: String, + ): Reservation { + val reservationEntity = reservationRepository.findByIdOrNull(reservationId) ?: throw ReservationNotFoundException() + val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() + val reservationUser = reservationEntity.user ?: throw ReservedYetException() + if (reservationUser.id != userEntity.id) { + throw AuthenticateException() + } + + val seatEntity = reservationEntity.seat + val performanceHallEntity = seatEntity.performanceHall + val performanceEventEntity = reservationEntity.performanceEvent + val performanceEntity = performanceEventEntity.performance val reservation = Reservation.fromEntity( - reservationEntity = targetReservation, - performanceEntity = targetPerformance, - performanceHallEntity = targetPerformanceHall, - seatEntity = targetSeat, - performanceEventEntity = targetPerformanceEvent, + reservationEntity = reservationEntity, + performanceEntity = performanceEntity, + performanceHallEntity = performanceHallEntity, + seatEntity = seatEntity, + performanceEventEntity = performanceEventEntity, ) + return reservation } @Transactional - fun cancelReserveSeat( + fun cancelReservedSeat( user: User, reservationId: String, ) { @@ -81,4 +91,4 @@ class SeatService( reservationEntity.reservationDate = null reservationEntity.reserved = false } -} \ No newline at end of file +} From bda171169c48467f8ab7e4d982fffed113455a19 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sat, 11 Jan 2025 13:27:12 +0900 Subject: [PATCH 023/162] style: seat ktlint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit seat폴더의 파일들에 ktlint 적용 --- .../interpark/seat/SeatException.kt | 3 +- .../interpark/seat/controller/Seat.kt | 4 +-- .../seat/persistence/ReservationEntity.kt | 3 +- .../seat/persistence/ReservationRepository.kt | 2 +- .../interpark/seat/persistence/SeatEntity.kt | 3 +- .../seat/persistence/SeatRepository.kt | 4 +-- .../seat/service/SeatCreationService.kt | 31 +++++++++---------- 7 files changed, 22 insertions(+), 28 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt index 700f715..89bc310 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt @@ -28,6 +28,7 @@ class ReservedYetException : SeatException( httpStatusCode = HttpStatus.CONFLICT, msg = "Not Reserved Yet", ) + class ReservationPermissionDeniedException : SeatException( errorCode = 0, httpStatusCode = HttpStatus.FORBIDDEN, @@ -38,4 +39,4 @@ class InValidHallTypeException : SeatException( errorCode = 0, httpStatusCode = HttpStatus.BAD_REQUEST, msg = "Invalid Hall Type", -) \ No newline at end of file +) diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Seat.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Seat.kt index 959a1f0..6ef0ff7 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Seat.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Seat.kt @@ -5,7 +5,7 @@ import com.wafflestudio.interpark.seat.persistence.SeatEntity data class Seat( val id: String, val seatNumber: Pair, - val price: Int + val price: Int, ) { companion object { fun fromEntity(entity: SeatEntity): Seat { @@ -16,4 +16,4 @@ data class Seat( ) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt index bd47ea0..6c17a15 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt @@ -10,7 +10,6 @@ import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne -import jakarta.persistence.OneToOne import jakarta.persistence.Table import java.time.LocalDate @@ -33,4 +32,4 @@ class ReservationEntity( var reservationDate: LocalDate?, @Column(name = "reserved") var reserved: Boolean = false, -) \ No newline at end of file +) diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt index 268056e..5912ebf 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt @@ -6,4 +6,4 @@ interface ReservationRepository : JpaRepository { fun findByUserId(userId: String): List fun findByPerformanceEventIdAndReservedIsFalse(performanceEventId: String): List -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt index 99488b4..7cfe9df 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt @@ -2,7 +2,6 @@ package com.wafflestudio.interpark.seat.persistence import com.wafflestudio.interpark.performance.persistence.PerformanceHallEntity import jakarta.persistence.* -import java.time.LocalDate @Entity @Table(name = "seats") @@ -17,4 +16,4 @@ class SeatEntity( val seatNumber: Pair, @Column(name = "price") var price: Int = 10000, -) \ No newline at end of file +) diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatRepository.kt index fde4e27..639c318 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatRepository.kt @@ -2,9 +2,7 @@ package com.wafflestudio.interpark.seat.persistence import com.wafflestudio.interpark.performance.persistence.PerformanceHallEntity import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Query -import java.time.LocalDate interface SeatRepository : JpaRepository { fun findByPerformanceHall(performanceHall: PerformanceHallEntity): List -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatCreationService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatCreationService.kt index b95dcb6..cc8c923 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatCreationService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatCreationService.kt @@ -12,7 +12,6 @@ import com.wafflestudio.interpark.seat.persistence.SeatRepository import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.time.LocalDate @Service class SeatCreationService( @@ -24,21 +23,20 @@ class SeatCreationService( @Transactional fun createSeats( performanceHallId: String, - type: String? + type: String?, ) { val performanceHallEntity = performanceHallRepository.findByIdOrNull(performanceHallId) ?: throw PerformanceHallNotFoundException() when (type) { "DEFAULT" -> { // 10행 10열의 공연장 - // TODO: 병렬처리하기 - for(row in 1..10) { + for (row in 1..10) { for (col in 1..10) { seatRepository.save( SeatEntity( performanceHall = performanceHallEntity, seatNumber = Pair(row, col), - price = 10000 - ) + price = 10000, + ), ) } } @@ -50,18 +48,17 @@ class SeatCreationService( } @Transactional - fun createEmptyReservations( - performanceEventId: String, - ) { + fun createEmptyReservations(performanceEventId: String) { val performanceEventEntity = performanceEventRepository.findByIdOrNull(performanceEventId) ?: throw PerformanceEventNotFoundException() val seats = seatRepository.findByPerformanceHall(performanceEventEntity.performanceHall) - val emptyReservations = seats.map { - ReservationEntity( - seat = it, - performanceEvent = performanceEventEntity, - reservationDate = null, - ) - } + val emptyReservations = + seats.map { + ReservationEntity( + seat = it, + performanceEvent = performanceEventEntity, + reservationDate = null, + ) + } reservationRepository.saveAll(emptyReservations) } -} \ No newline at end of file +} From e226951625517298a84cf21e3a23c77627d5ac42 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sat, 11 Jan 2025 15:06:32 +0900 Subject: [PATCH 024/162] modify MakeFile --- Makefile | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index eb233b8..3c3eb57 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,20 @@ +# 운영체제 감지 +OS := $(shell uname) + +# gradlew 명령어 설정 +ifeq ($(OS),Linux) + GRADLE_CMD = ./gradlew +else ifeq ($(OS),Darwin) + GRADLE_CMD = ./gradlew +else ifneq (,$(findstring MINGW,$(OS))) + GRADLE_CMD = gradlew +else + GRADLE_CMD = ./gradlew +endif + +# 실행 명령어 all: docker compose up -d mysql - ./gradlew build + $(GRADLE_CMD) build docker build -t myapp:1.0 . docker compose up \ No newline at end of file From 8e64ffab5a518ebedd3b6128bc21316b9a0f0b6f Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sat, 11 Jan 2025 16:04:22 +0900 Subject: [PATCH 025/162] =?UTF-8?q?=EA=B3=B5=EC=97=B0=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../performance/controller/Performance.kt | 26 ++++++++++++++----- .../persistence/PerformanceEventRepository.kt | 2 +- .../performance/service/PerformanceService.kt | 25 ++++++++++++++++-- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt index 62c6f06..90aea87 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt @@ -2,11 +2,15 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import com.wafflestudio.interpark.performance.persistence.PerformanceEntity +import com.wafflestudio.interpark.performance.persistence.PerformanceEventEntity +import com.wafflestudio.interpark.performance.persistence.PerformanceHallEntity import java.time.LocalDate data class Performance( val id: String, val title: String, + val hallName: String, + val performanceDates: List, val detail: String, val category: PerformanceCategory, val posterUri: String, @@ -16,14 +20,22 @@ data class Performance( // val performanceEventList: List, ) { companion object { - fun fromEntity(entity: PerformanceEntity): Performance { + fun fromEntity( + performanceEntity: PerformanceEntity, + performanceEventEntities: List, + performanceHallEntity: PerformanceHallEntity, + ): Performance { return Performance( - id = entity.id!!, - title = entity.title, - detail = entity.detail, - category = entity.category, - posterUri = entity.posterUri, - backdropImageUri = entity.backdropImageUri + id = performanceEntity.id!!, + title = performanceEntity.title, + hallName = performanceHallEntity.name, + performanceDates = performanceEventEntities.map { + it.startAt.atZone(java.time.ZoneId.of("Asia/Seoul")).toLocalDate() + }.distinct(), + detail = performanceEntity.detail, + category = performanceEntity.category, + posterUri = performanceEntity.posterUri, + backdropImageUri = performanceEntity.backdropImageUri ) } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventRepository.kt index 859c204..7adad1f 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventRepository.kt @@ -3,5 +3,5 @@ package com.wafflestudio.interpark.performance.persistence import org.springframework.data.jpa.repository.JpaRepository interface PerformanceEventRepository : JpaRepository { - + fun findAllByPerformanceId(performanceId: String): List } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt index 59f0e94..1a20469 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt @@ -3,6 +3,7 @@ package com.wafflestudio.interpark.performance.service import com.wafflestudio.interpark.performance.PerformanceNotFoundException import com.wafflestudio.interpark.performance.controller.Performance import com.wafflestudio.interpark.performance.persistence.* +import com.wafflestudio.interpark.user.persistence.UserRepository import org.springframework.data.jpa.domain.Specification import org.springframework.stereotype.Service import org.springframework.data.repository.findByIdOrNull @@ -10,6 +11,8 @@ import org.springframework.data.repository.findByIdOrNull @Service class PerformanceService( private val performanceRepository: PerformanceRepository, + private val performanceEventRepository: PerformanceEventRepository, + private val performanceHallRepository: PerformanceHallRepository, ) { fun searchPerformance( title: String?, @@ -32,13 +35,31 @@ class PerformanceService( val performanceEntities = performanceRepository.findAll(spec) // DTO 변환 - return performanceEntities.map { Performance.fromEntity(it) } + return performanceEntities.map { performanceEntity -> + val performanceEventEntities = performanceEventRepository.findAllByPerformanceId(performanceEntity.id!!) // 관련 이벤트들 + val performanceHallEntity = performanceEventEntities.first().performanceHall // 관련 공연장 + + Performance.fromEntity( + performanceEntity = performanceEntity, + performanceHallEntity = performanceHallEntity, + performanceEventEntities = performanceEventEntities + ) + } } fun getAllPerformance(): List { return performanceRepository .findAll() - .map { Performance.fromEntity(it) }; + .map { performanceEntity -> + val performanceEventEntities = performanceEventRepository.findAllByPerformanceId(performanceEntity.id!!) + val performanceHallEntity = performanceEventEntities.first().performanceHall + + Performance.fromEntity( + performanceEntity = performanceEntity, + performanceHallEntity = performanceHallEntity, + performanceEventEntities = performanceEventEntities + ) + }; } fun createPerformance( From ce54e675f2c718e8c5b2dcb0928695a538bbc783 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sat, 11 Jan 2025 17:08:27 +0900 Subject: [PATCH 026/162] fromEntity parameter change: receive Entities -> receive DTOs --- .../performance/controller/Performance.kt | 12 +++++----- .../performance/service/PerformanceService.kt | 22 ++++++++++--------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt index 90aea87..75a0bf8 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt @@ -2,8 +2,6 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import com.wafflestudio.interpark.performance.persistence.PerformanceEntity -import com.wafflestudio.interpark.performance.persistence.PerformanceEventEntity -import com.wafflestudio.interpark.performance.persistence.PerformanceHallEntity import java.time.LocalDate data class Performance( @@ -22,16 +20,16 @@ data class Performance( companion object { fun fromEntity( performanceEntity: PerformanceEntity, - performanceEventEntities: List, - performanceHallEntity: PerformanceHallEntity, + performanceEvents: List?, + performanceHall: PerformanceHall?, ): Performance { return Performance( id = performanceEntity.id!!, title = performanceEntity.title, - hallName = performanceHallEntity.name, - performanceDates = performanceEventEntities.map { + hallName = performanceHall?.name ?: "", + performanceDates = performanceEvents?.map { it.startAt.atZone(java.time.ZoneId.of("Asia/Seoul")).toLocalDate() - }.distinct(), + }?.distinct() ?: emptyList(), detail = performanceEntity.detail, category = performanceEntity.category, posterUri = performanceEntity.posterUri, diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt index 1a20469..0f53dad 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt @@ -2,8 +2,9 @@ package com.wafflestudio.interpark.performance.service import com.wafflestudio.interpark.performance.PerformanceNotFoundException import com.wafflestudio.interpark.performance.controller.Performance +import com.wafflestudio.interpark.performance.controller.PerformanceEvent +import com.wafflestudio.interpark.performance.controller.PerformanceHall import com.wafflestudio.interpark.performance.persistence.* -import com.wafflestudio.interpark.user.persistence.UserRepository import org.springframework.data.jpa.domain.Specification import org.springframework.stereotype.Service import org.springframework.data.repository.findByIdOrNull @@ -12,7 +13,6 @@ import org.springframework.data.repository.findByIdOrNull class PerformanceService( private val performanceRepository: PerformanceRepository, private val performanceEventRepository: PerformanceEventRepository, - private val performanceHallRepository: PerformanceHallRepository, ) { fun searchPerformance( title: String?, @@ -36,13 +36,14 @@ class PerformanceService( // DTO 변환 return performanceEntities.map { performanceEntity -> - val performanceEventEntities = performanceEventRepository.findAllByPerformanceId(performanceEntity.id!!) // 관련 이벤트들 - val performanceHallEntity = performanceEventEntities.first().performanceHall // 관련 공연장 + val performanceEventEntities = performanceEventRepository.findAllByPerformanceId(performanceEntity.id!!) + val performanceEvents = performanceEventEntities.map{ PerformanceEvent.fromEntity(it) } + val performanceHall = PerformanceHall.fromEntity(performanceEventEntities.first().performanceHall) Performance.fromEntity( performanceEntity = performanceEntity, - performanceHallEntity = performanceHallEntity, - performanceEventEntities = performanceEventEntities + performanceHall = performanceHall, + performanceEvents = performanceEvents ) } } @@ -52,12 +53,13 @@ class PerformanceService( .findAll() .map { performanceEntity -> val performanceEventEntities = performanceEventRepository.findAllByPerformanceId(performanceEntity.id!!) - val performanceHallEntity = performanceEventEntities.first().performanceHall + val performanceEvents = performanceEventEntities.map{ PerformanceEvent.fromEntity(it) } + val performanceHall = PerformanceHall.fromEntity(performanceEventEntities.first().performanceHall) Performance.fromEntity( performanceEntity = performanceEntity, - performanceHallEntity = performanceHallEntity, - performanceEventEntities = performanceEventEntities + performanceHall = performanceHall, + performanceEvents = performanceEvents ) }; } @@ -79,7 +81,7 @@ class PerformanceService( ).let{ performanceRepository.save(it) } - return Performance.fromEntity(newPerformanceEntity) + return Performance.fromEntity(newPerformanceEntity, null, null) } fun deletePerformance(performanceId: String) { From 28ff62a0fcc5516eb638ae7e5390767b3294e118 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sat, 11 Jan 2025 17:20:42 +0900 Subject: [PATCH 027/162] modify searchPerformance endpoint URL --- .../interpark/performance/controller/PerformanceController.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index 34bc0d9..c65c47a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -3,8 +3,6 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import io.swagger.v3.oas.annotations.Operation import com.wafflestudio.interpark.performance.service.PerformanceService -import com.wafflestudio.interpark.user.controller.User -import com.wafflestudio.interpark.user.AuthUser import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @@ -12,7 +10,7 @@ import org.springframework.web.bind.annotation.* class PerformanceController( private val performanceService: PerformanceService, ) { - @GetMapping("v1/performance/search") + @GetMapping("/api/v1/performance/search") @Operation( summary = "공연 조회", description = "제목과 카테고리에 해당하는 공연들의 리스트를 반환합니다." From d071a7ac7ff2c99f37694da5ce66c507eb2d0aa3 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sat, 11 Jan 2025 17:52:20 +0900 Subject: [PATCH 028/162] chore: makefile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OS 따라서 다르게 작동하게 수정 --- Makefile | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 11d4b4d..cc095ee 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,12 @@ +OS := $(shell uname) + +ifeq ($(OS), MINGW32_NT-6.2) + GRADLE_CMD := gradlew +else + GRADLE_CMD := ./gradlew +endif + all: - gradlew build + $(GRADLE_CMD) build docker build -t myapp:1.0 . docker compose up \ No newline at end of file From 9fe0593a7f400c6c0b473ed8962f2c3e9b3961c9 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sun, 12 Jan 2025 18:01:10 +0900 Subject: [PATCH 029/162] =?UTF-8?q?=EA=B3=B5=EC=97=B0=201=EA=B0=9C=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A0=95=EB=B3=B4=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 15 ++++---------- .../controller/PerformanceController.kt | 16 ++++++++++++++- .../performance/service/PerformanceService.kt | 20 +++++++++++++++++-- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/Makefile b/Makefile index 3c3eb57..05b5acf 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,13 @@ -# 운영체제 감지 -OS := $(shell uname) +OS := $(shell uname -s) -# gradlew 명령어 설정 -ifeq ($(OS),Linux) - GRADLE_CMD = ./gradlew -else ifeq ($(OS),Darwin) - GRADLE_CMD = ./gradlew -else ifneq (,$(findstring MINGW,$(OS))) - GRADLE_CMD = gradlew +ifeq ($(OS), MINGW32_NT-6.2) + GRADLE_CMD := gradlew else - GRADLE_CMD = ./gradlew + GRADLE_CMD := ./gradlew endif # 실행 명령어 all: - docker compose up -d mysql $(GRADLE_CMD) build docker build -t myapp:1.0 . docker compose up \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index c65c47a..cf18202 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -37,10 +37,22 @@ class PerformanceController( request.category, request.posterUri, request.backdropImageUri - ); + ) return ResponseEntity.ok(newPerformance) } + @GetMapping("/api/v1/performance/{performanceId}") + @Operation( + summary = "공연 상세정보 반환", + description = "공연을 선택했을 때 화면에 보여지는 상세 정보를 반환합니다." + ) + fun getPerformanceDetail( + @PathVariable performanceId: String, + ) : ResponseEntity { + val queriedPerformance = performanceService.getPerformanceDetail(performanceId) + return ResponseEntity.ok(queriedPerformance) + } + @DeleteMapping("/admin/v1/performance/{performanceId}") fun deletePerformance( @PathVariable performanceId: String @@ -62,3 +74,5 @@ data class CreatePerformanceRequest( ) typealias CreatePerformanceResponse = Performance + +typealias GetPerformanceDetailResponse = Performance diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt index 0f53dad..934bf95 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt @@ -37,8 +37,14 @@ class PerformanceService( // DTO 변환 return performanceEntities.map { performanceEntity -> val performanceEventEntities = performanceEventRepository.findAllByPerformanceId(performanceEntity.id!!) - val performanceEvents = performanceEventEntities.map{ PerformanceEvent.fromEntity(it) } - val performanceHall = PerformanceHall.fromEntity(performanceEventEntities.first().performanceHall) + val performanceEvents = if (performanceEventEntities.isEmpty()) { + null + } else { + performanceEventEntities.map { PerformanceEvent.fromEntity(it) } + } + val performanceHall = performanceEventEntities.firstOrNull()?.let { + PerformanceHall.fromEntity(it.performanceHall) + } Performance.fromEntity( performanceEntity = performanceEntity, @@ -63,6 +69,16 @@ class PerformanceService( ) }; } + + fun getPerformanceDetail(performanceId: String): Performance { + val performanceEntity: PerformanceEntity = performanceRepository.findById(performanceId) + .orElseThrow{ PerformanceNotFoundException() } + val performanceEventEntities = performanceEventRepository.findAllByPerformanceId(performanceEntity.id!!) + val performanceEvents = performanceEventEntities.map{ PerformanceEvent.fromEntity(it) } + val performanceHall = PerformanceHall.fromEntity(performanceEventEntities.first().performanceHall) + + return Performance.fromEntity(performanceEntity, performanceEvents, performanceHall) + } fun createPerformance( title: String, From a3bc07a032aeff77e4871724557a7c5b39021afd Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sun, 12 Jan 2025 18:24:32 +0900 Subject: [PATCH 030/162] minor modify: change how null type is handled --- .../interpark/performance/service/PerformanceEventService.kt | 1 + .../interpark/performance/service/PerformanceService.kt | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt index ac970d8..4861ac4 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt @@ -40,6 +40,7 @@ class PerformanceEventService( } return PerformanceEvent.fromEntity(newPerformanceEventEntity) } + fun deletePerformanceEvent(performanceEventId: String) { val deletePerformanceEventEntity: PerformanceEventEntity = performanceEventRepository.findByIdOrNull(performanceEventId) ?: throw PerformanceEventNotFoundException() diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt index 934bf95..59a64d8 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt @@ -71,8 +71,7 @@ class PerformanceService( } fun getPerformanceDetail(performanceId: String): Performance { - val performanceEntity: PerformanceEntity = performanceRepository.findById(performanceId) - .orElseThrow{ PerformanceNotFoundException() } + val performanceEntity: PerformanceEntity = performanceRepository.findByIdOrNull(performanceId) ?: throw PerformanceNotFoundException() val performanceEventEntities = performanceEventRepository.findAllByPerformanceId(performanceEntity.id!!) val performanceEvents = performanceEventEntities.map{ PerformanceEvent.fromEntity(it) } val performanceHall = PerformanceHall.fromEntity(performanceEventEntities.first().performanceHall) From a7beb9c356d05a8f1012e55e0eeacd188555f640 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sun, 12 Jan 2025 23:45:13 +0900 Subject: [PATCH 031/162] =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=88=98=EC=A0=95,=20=EA=B3=B5=EC=97=B0=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=EB=8A=94=20=EC=9D=BC=EB=8B=A8=20=EA=B3=B5?= =?UTF-8?q?=EC=97=B0=20=EA=B8=B0=EA=B0=84=EC=9D=98=20=EC=B2=AB=EB=82=A0?= =?UTF-8?q?=EA=B3=BC=20=EB=A7=88=EC=A7=80=EB=A7=89=EB=82=A0=EB=A7=8C=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=ED=95=98=EC=98=80=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interpark/config/DataInitializer.kt | 447 ++++++++++-------- .../persistence/PerformanceHallRepository.kt | 2 +- .../persistence/PerformanceRepository.kt | 2 +- .../service/PerformanceEventService.kt | 23 +- 4 files changed, 260 insertions(+), 214 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index 64b2183..045f138 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -1,243 +1,280 @@ package com.wafflestudio.interpark.config +import com.wafflestudio.interpark.performance.PerformanceHallNotFoundException +import com.wafflestudio.interpark.performance.PerformanceNotFoundException import com.wafflestudio.interpark.performance.persistence.PerformanceCategory -import com.wafflestudio.interpark.performance.persistence.PerformanceEntity -import com.wafflestudio.interpark.performance.persistence.PerformanceHallEntity -import com.wafflestudio.interpark.performance.persistence.PerformanceRepository import com.wafflestudio.interpark.performance.persistence.PerformanceHallRepository +import com.wafflestudio.interpark.performance.persistence.PerformanceRepository +import com.wafflestudio.interpark.performance.service.PerformanceEventService +import com.wafflestudio.interpark.performance.service.PerformanceHallService +import com.wafflestudio.interpark.performance.service.PerformanceService import org.springframework.boot.CommandLineRunner import org.springframework.context.annotation.Configuration -import java.time.LocalDate @Configuration class DataInitializer( - private val performanceHallRepository: PerformanceHallRepository, + private val performanceService: PerformanceService, + private val performanceEventService: PerformanceEventService, + private val performanceHallService: PerformanceHallService, private val performanceRepository: PerformanceRepository, + private val performanceHallRepository: PerformanceHallRepository, ) : CommandLineRunner { override fun run(vararg args: String?) { - // 1) 공연장(Hall) 데이터 넣기 - val BlueSquare_ShinHanCardHall = performanceHallRepository.save( - PerformanceHallEntity( - name = "블루스퀘어 신한카드홀", - address = "서울 용산구 한남동 이태원로", - maxAudience = 100 - ) + + // 1) 공연장 데이터 넣기 + performanceHallService.createPerformanceHall( + name = "블루스퀘어 신한카드홀", + address = "서울 용산구 한남동 이태원로", + maxAudience = 100 ) - val BlueSquare_MasterCardHall = performanceHallRepository.save( - PerformanceHallEntity( - name = "블루스퀘어 마스터카드홀", - address = "서울 용산구 한남동 이태원로", - maxAudience = 100 - ) + performanceHallService.createPerformanceHall( + name = "블루스퀘어 마스터카드홀", + address = "서울 용산구 한남동 이태원로", + maxAudience = 100 ) - val SeoulArtsCenter_OperaHouse = performanceHallRepository.save( - PerformanceHallEntity( - name = "예술의전당 오페라극장", - address = "서울 서초구 남부순환로", - maxAudience = 100 - ) + performanceHallService.createPerformanceHall( + name = "예술의전당 오페라극장", + address = "서울 서초구 남부순환로", + maxAudience = 100 ) - val SeoulArtsCenter_ConcertHall = performanceHallRepository.save( - PerformanceHallEntity( - name = "예술의전당 콘서트홀", - address = "서울 서초구 남부순환로", - maxAudience = 100 - ) + performanceHallService.createPerformanceHall( + name = "예술의전당 콘서트홀", + address = "서울 서초구 남부순환로", + maxAudience = 100 ) - val LGArtCenterSeoul_SIGNATUREHAll = performanceHallRepository.save( - PerformanceHallEntity( - name = "LG아트센터 서울 SIGNATURE홀", - address = "서울 강서구 마곡동 마곡중앙로", - maxAudience = 100 - ) + performanceHallService.createPerformanceHall( + name = "LG아트센터 서울 SIGNATURE홀", + address = "서울 강서구 마곡동 마곡중앙로", + maxAudience = 100 ) - val LGArtCenterSeoul_UPlusStage = performanceHallRepository.save( - PerformanceHallEntity( - name = "LG아트센터 서울 U+ 스테이지", - address = "서울 강서구 마곡동 마곡중앙로", - maxAudience = 100 - ) + performanceHallService.createPerformanceHall( + name = "LG아트센터 서울 U+ 스테이지", + address = "서울 강서구 마곡동 마곡중앙로", + maxAudience = 100 ) - val OlympicPark_OlympicHall = performanceHallRepository.save( - PerformanceHallEntity( - name = "올림픽공원 올림픽홀", - address = "서울 송파구 방이동", - maxAudience = 100 - ) + performanceHallService.createPerformanceHall( + name = "올림픽공원 올림픽홀", + address = "서울 송파구 방이동", + maxAudience = 100 ) - val GoyangSportsComplex_MainStadium = performanceHallRepository.save( - PerformanceHallEntity( - name = "고양종합운동장 주경기장", - address = "경기도 고양시 일산서구 대화동 중앙로", - maxAudience = 100 - ) + performanceHallService.createPerformanceHall( + name = "고양종합운동장 주경기장", + address = "경기도 고양시 일산서구 대화동 중앙로", + maxAudience = 100 ) - val SejongCenter_GrandTheater = performanceHallRepository.save( - PerformanceHallEntity( - name = "세종문화회관 대극장", - address = "서울 종로구 세종대로", - maxAudience = 100 - ) + performanceHallService.createPerformanceHall( + name = "세종문화회관 대극장", + address = "서울 종로구 세종대로", + maxAudience = 100 ) - val SejongCenter_MTheater = performanceHallRepository.save( - PerformanceHallEntity( - name = "세종문화회관 M씨어터", - address = "서울 종로구 세종대로", - maxAudience = 100 - ) + performanceHallService.createPerformanceHall( + name = "세종문화회관 M씨어터", + address = "서울 종로구 세종대로", + maxAudience = 100 ) - // 2) PerformanceEntity 여러개 생성 - val performanceList = listOf( - // 뮤지컬 - PerformanceEntity( - title = "뮤지컬 지킬앤하이드", - detail = "지금 이 순간, 끝나지 않는 신화", - category = PerformanceCategory.MUSICAL, - posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24013928_p.gif", - backdropImageUri = "http://example.com/backdrop/jekyll.jpg", - ), - PerformanceEntity( - //hall = LGArtCenterSeoul_SIGNATUREHAll, - title = "마타하리", - detail = "She's BACK!", - category = PerformanceCategory.MUSICAL, - //sales = 2000, - //dates = listOf(LocalDate.of(2024, 12, 5), LocalDate.of(2025, 3, 2)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/L0/L0000106_p.gif", - backdropImageUri = "http://example.com/backdrop/phantom.jpg", - //seatIds = listOf("B1", "B2", "B3"), - //reviewIds = listOf("review3", "review4") + // 2) Performance 데이터 넣기 + performanceService.createPerformance( + title = "뮤지컬 지킬앤하이드", + detail = "지금 이 순간, 끝나지 않는 신화", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24013928_p.gif", + backdropImageUri = "http://example.com/backdrop/jekyll.jpg" + ) + performanceService.createPerformance( + title = "마타하리", + detail = "She's BACK!", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/L0/L0000106_p.gif", + backdropImageUri = "http://example.com/backdrop/phantom.jpg" + ) + performanceService.createPerformance( + title = "웃는남자", + detail = "부자들의 낙원은 가난한 자들의 지옥으로 세워진 것이다", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24016737_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "2025 기리보이 콘서트", + detail = "2252:2522", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24018543_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "2025 검정치마 단독공연", + detail = "SONGS TO BRING YOU HOME", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000084_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "콜드플레이 내한공연", + detail = "MUSIC of the SPHERES", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24013437_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "브루스 리우 피아노 리사이틀", + detail = "TCHAIKOVSKY | MENDELSSOHN | SCRIABIN | PROKOFIEV", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24016119_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "크리스티안 테츨라프 바이올린 리사이틀", + detail = "SUK | BRAHMS | SZYMANOWSKI | FRANCK", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24015137_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "발레의 별빛, 글로벌 발레스타 초청 갈라공연", + detail = "전 세계가 먼저 찾는 한국 스타 무용수들의 향연!", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/P0/P0004046_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "연극 애나엑스", + detail = "ANNA X", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/L0/L0000107_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "연극 타인의 삶", + detail = "영화 타인의 삶 원작", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/L0/L0000104_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "세일즈맨의 죽음", + detail = "현 희곡의 거장 '아서 밀러'의 대표작 연극<세일즈맨의 죽음>이 돌아왔다!", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24017573_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + + // 3) Performance Event 데이터 넣기 + val performanceEvents = listOf( + Triple( + "블루스퀘어 신한카드홀", + "뮤지컬 지킬앤하이드", + listOf( + listOf("2024-11-29T16:00:00", "2024-11-29T18:00:00"), + listOf("2025-05-18T16:00:00", "2025-05-18T18:00:00") + ) ), - PerformanceEntity( - //hall = SeoulArtsCenter_OperaHouse, - title = "웃는남자", - detail = "부자들의 낙원은 가난한 자들의 지옥으로 세워진 것이다", - category = PerformanceCategory.MUSICAL, - //sales = 500, - //dates = listOf(LocalDate.of(2025, 1, 9), LocalDate.of(2025, 3, 9)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24016737_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = listOf("C1", "C2", "C3"), - //reviewIds = listOf("review5") + Triple( + "LG아트센터 서울 SIGNATURE홀", + "마타하리", + listOf( + listOf("2024-12-05T16:00:00", "2024-12-05T18:00:00"), + listOf("2025-03-02T16:00:00", "2025-03-02T18:00:00") + ) ), - - // 콘서트 - PerformanceEntity( - //hall = BlueSquare_MasterCardHall, - title = "2025 기리보이 콘서트", - detail = "2252:2522", - category = PerformanceCategory.CONCERT, - //sales = 0, - //dates = listOf(LocalDate.of(2025, 2, 1), LocalDate.of(2025, 2, 2)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24018543_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = emptyList(), - //reviewIds = emptyList() + Triple( + "예술의전당 오페라극장", + "웃는남자", + listOf( + listOf("2025-01-09T16:00:00", "2025-01-09T18:00:00"), + listOf("2025-03-09T16:00:00", "2025-03-09T18:00:00") + ) ), - PerformanceEntity( - //hall = OlympicPark_OlympicHall, - title = "2025 검정치마 단독공연", - detail = "SONGS TO BRING YOU HOME", - category = PerformanceCategory.CONCERT, - //sales = 0, - //dates = listOf(LocalDate.of(2025, 2, 1), LocalDate.of(2025, 2, 2)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000084_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = emptyList(), - //reviewIds = emptyList() + Triple( + "블루스퀘어 마스터카드홀", + "2025 기리보이 콘서트", + listOf( + listOf("2025-02-01T16:00:00", "2025-02-01T18:00:00"), + listOf("2025-02-02T16:00:00", "2025-02-02T18:00:00") + ) ), - PerformanceEntity( - //hall = GoyangSportsComplex_MainStadium, - title = "콜드플레이 내한공연", - detail = "MUSIC of the SPHERES", - category = PerformanceCategory.CONCERT, - //sales = 0, - //dates = listOf(LocalDate.of(2025, 4, 16), LocalDate.of(2025, 4, 25)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24013437_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = emptyList(), - //reviewIds = emptyList() + Triple( + "올림픽공원 올림픽홀", + "2025 검정치마 단독공연", + listOf( + listOf("2025-02-01T16:00:00", "2025-02-01T18:00:00"), + listOf("2025-02-02T16:00:00", "2025-02-02T18:00:00") + ) ), - - // 클래식 - PerformanceEntity( - //hall = SeoulArtsCenter_ConcertHall, - title = "브루스 리우 피아노 리사이틀", - detail = "TCHAIKOVSKY | MENDELSSOHN | SCRIABIN | PROKOFIEV", - category = PerformanceCategory.CLASSIC, - //sales = 0, - //dates = listOf(LocalDate.of(2025, 5, 11)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24016119_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = emptyList(), - //reviewIds = emptyList() + Triple( + "고양종합운동장 주경기장", + "콜드플레이 내한공연", + listOf( + listOf("2025-04-16T16:00:00", "2025-04-16T18:00:00"), + listOf("2025-04-25T16:00:00", "2025-04-25T18:00:00") + ) ), - PerformanceEntity( - //hall = SeoulArtsCenter_ConcertHall, - title = "크리스티안 테츨라프 바이올린 리사이틀", - detail = "SUK | BRAHMS | SZYMANOWSKI | FRANCK", - category = PerformanceCategory.CLASSIC, - //sales = 0, - //dates = listOf(LocalDate.of(2025, 5, 1)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24015137_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = emptyList(), - //reviewIds = emptyList() + Triple( + "예술의전당 콘서트홀", + "브루스 리우 피아노 리사이틀", + listOf( + listOf("2025-05-11T16:00:00", "2025-05-11T18:00:00") + ) ), - PerformanceEntity( - //hall = SejongCenter_GrandTheater, - title = "발레의 별빛, 글로벌 발레스타 초청 갈라공연", - detail = "전 세계가 먼저 찾는 한국 스타 무용수들의 향연!", - category = PerformanceCategory.CLASSIC, - //sales = 0, - //dates = listOf(LocalDate.of(2025, 1, 11), LocalDate.of(2025, 1, 12)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/P0/P0004046_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = emptyList(), - //reviewIds = emptyList() + Triple( + "예술의전당 콘서트홀", + "크리스티안 테츨라프 바이올린 리사이틀", + listOf( + listOf("2025-05-01T16:00:00", "2025-05-01T18:00:00") + ) ), - - // 연극 - PerformanceEntity( - //hall = LGArtCenterSeoul_UPlusStage, - title = "연극 애나엑스", - detail = "ANNA X", - category = PerformanceCategory.PLAY, - //sales = 0, - //dates = listOf(LocalDate.of(2025, 1, 28), LocalDate.of(2025, 3, 16)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/L0/L0000107_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = emptyList(), - //reviewIds = emptyList() + Triple( + "세종문화회관 대극장", + "발레의 별빛, 글로벌 발레스타 초청 갈라공연", + listOf( + listOf("2025-01-11T16:00:00", "2025-01-11T18:00:00"), + listOf("2025-01-12T16:00:00", "2025-01-12T18:00:00") + ) ), - PerformanceEntity( - //hall = LGArtCenterSeoul_UPlusStage, - title = "연극 타인의 삶", - detail = "영화 타인의 삶 원작", - category = PerformanceCategory.PLAY, - //sales = 0, - //dates = listOf(LocalDate.of(2025, 1, 28), LocalDate.of(2025, 3, 16)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/L0/L0000104_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = emptyList(), - //reviewIds = emptyList() + Triple( + "LG아트센터 서울 U+ 스테이지", + "연극 애나엑스", + listOf( + listOf("2025-01-28T16:00:00", "2025-01-28T18:00:00"), + listOf("2025-03-16T16:00:00", "2025-03-16T18:00:00") + ) ), - PerformanceEntity( - //hall = SejongCenter_MTheater, - title = "세일즈맨의 죽음", - detail = "현 희곡의 거장 '아서 밀러'의 대표작 연극<세일즈맨의 죽음>이 돌아왔다!", - category = PerformanceCategory.PLAY, - //sales = 0, - //dates = listOf(LocalDate.of(2025, 1, 7), LocalDate.of(2025, 3, 3)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24017573_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = emptyList(), - //reviewIds = emptyList() + Triple( + "LG아트센터 서울 U+ 스테이지", + "연극 타인의 삶", + listOf( + listOf("2025-01-28T16:00:00", "2025-01-28T18:00:00"), + listOf("2025-03-16T16:00:00", "2025-03-16T18:00:00") + ) ), + Triple( + "세종문화회관 M씨어터", + "세일즈맨의 죽음", + listOf( + listOf("2025-01-07T16:00:00", "2025-01-07T18:00:00"), + listOf("2025-03-03T16:00:00", "2025-03-03T18:00:00") + ) + ) ) - // 3) 한번에 저장 - performanceRepository.saveAll(performanceList) + performanceEvents.forEach { (performanceTitle, hallName, eventTimes) -> + // PerformanceRepository를 통해 공연 조회 + val performance = performanceRepository.findByTitle(performanceTitle) + ?: throw PerformanceNotFoundException() + + // PerformanceHallRepository를 통해 공연장 조회 + val hall = performanceHallRepository.findByName(hallName) + ?: throw PerformanceHallNotFoundException() + + // 이벤트 생성 + eventTimes.forEach { (startAt, endAt) -> + performanceEventService.createPerformanceEvent( + performanceId = performance.id!!, + performanceHallId = hall.id!!, + startAt = startAt, + endAt = endAt + ) + } + } } } \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallRepository.kt index 8862e05..cf98f23 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallRepository.kt @@ -3,5 +3,5 @@ package com.wafflestudio.interpark.performance.persistence import org.springframework.data.jpa.repository.JpaRepository interface PerformanceHallRepository : JpaRepository { - + fun findByName(name: String): PerformanceHallEntity? } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceRepository.kt index 89ffae2..da63de4 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceRepository.kt @@ -6,5 +6,5 @@ import org.springframework.data.jpa.repository.JpaSpecificationExecutor interface PerformanceRepository : JpaRepository, JpaSpecificationExecutor { - + fun findByTitle(title: String): PerformanceEntity? } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt index 4861ac4..4416584 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt @@ -8,6 +8,8 @@ import org.springframework.stereotype.Service import org.springframework.data.repository.findByIdOrNull import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId @Service class PerformanceEventService( @@ -20,21 +22,28 @@ class PerformanceEventService( .findAll() .map { PerformanceEvent.fromEntity(it) }; } - + + fun parseKoreanTimeToInstant(koreanTime: String): Instant { + val koreanZone = ZoneId.of("Asia/Seoul") + return LocalDateTime.parse(koreanTime).atZone(koreanZone).toInstant() + } + fun createPerformanceEvent( performanceId: String, performanceHallId: String, - startAt: Instant, - endAt: Instant, + startAt: String, + endAt: String, ): PerformanceEvent { - val performanceEntity: PerformanceEntity = performanceRepository.findByIdOrNull(performanceId) ?: throw PerformanceNotFoundException() - val performanceHallEntity: PerformanceHallEntity = performanceHallRepository.findByIdOrNull(performanceHallId) ?: throw PerformanceHallNotFoundException() + val performanceEntity: PerformanceEntity = performanceRepository.findByIdOrNull(performanceId) + ?: throw PerformanceNotFoundException() + val performanceHallEntity: PerformanceHallEntity = performanceHallRepository.findByIdOrNull(performanceHallId) + ?: throw PerformanceHallNotFoundException() val newPerformanceEventEntity: PerformanceEventEntity = PerformanceEventEntity( id = "", performance = performanceEntity, performanceHall = performanceHallEntity, - startAt = startAt, - endAt = endAt, + startAt = parseKoreanTimeToInstant(startAt), + endAt = parseKoreanTimeToInstant(endAt), ).let{ performanceEventRepository.save(it) } From 4030c833a30c45aaa9577ed6c6ac7218fd63253f Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Mon, 13 Jan 2025 00:48:43 +0900 Subject: [PATCH 032/162] modify DataInitializer, SeatIntegrationTest --- .../interpark/config/DataInitializer.kt | 24 +++++++++---------- .../controller/PerformanceEventController.kt | 4 ++-- .../interpark/SeatIntegrationTest.kt | 9 +++---- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index 045f138..1f3dcfe 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -162,94 +162,94 @@ class DataInitializer( // 3) Performance Event 데이터 넣기 val performanceEvents = listOf( Triple( - "블루스퀘어 신한카드홀", "뮤지컬 지킬앤하이드", + "블루스퀘어 신한카드홀", listOf( listOf("2024-11-29T16:00:00", "2024-11-29T18:00:00"), listOf("2025-05-18T16:00:00", "2025-05-18T18:00:00") ) ), Triple( - "LG아트센터 서울 SIGNATURE홀", "마타하리", + "LG아트센터 서울 SIGNATURE홀", listOf( listOf("2024-12-05T16:00:00", "2024-12-05T18:00:00"), listOf("2025-03-02T16:00:00", "2025-03-02T18:00:00") ) ), Triple( - "예술의전당 오페라극장", "웃는남자", + "예술의전당 오페라극장", listOf( listOf("2025-01-09T16:00:00", "2025-01-09T18:00:00"), listOf("2025-03-09T16:00:00", "2025-03-09T18:00:00") ) ), Triple( - "블루스퀘어 마스터카드홀", "2025 기리보이 콘서트", + "블루스퀘어 마스터카드홀", listOf( listOf("2025-02-01T16:00:00", "2025-02-01T18:00:00"), listOf("2025-02-02T16:00:00", "2025-02-02T18:00:00") ) ), Triple( - "올림픽공원 올림픽홀", "2025 검정치마 단독공연", + "올림픽공원 올림픽홀", listOf( listOf("2025-02-01T16:00:00", "2025-02-01T18:00:00"), listOf("2025-02-02T16:00:00", "2025-02-02T18:00:00") ) ), Triple( - "고양종합운동장 주경기장", "콜드플레이 내한공연", + "고양종합운동장 주경기장", listOf( listOf("2025-04-16T16:00:00", "2025-04-16T18:00:00"), listOf("2025-04-25T16:00:00", "2025-04-25T18:00:00") ) ), Triple( - "예술의전당 콘서트홀", "브루스 리우 피아노 리사이틀", + "예술의전당 콘서트홀", listOf( listOf("2025-05-11T16:00:00", "2025-05-11T18:00:00") ) ), Triple( - "예술의전당 콘서트홀", "크리스티안 테츨라프 바이올린 리사이틀", + "예술의전당 콘서트홀", listOf( listOf("2025-05-01T16:00:00", "2025-05-01T18:00:00") ) ), Triple( - "세종문화회관 대극장", "발레의 별빛, 글로벌 발레스타 초청 갈라공연", + "세종문화회관 대극장", listOf( listOf("2025-01-11T16:00:00", "2025-01-11T18:00:00"), listOf("2025-01-12T16:00:00", "2025-01-12T18:00:00") ) ), Triple( - "LG아트센터 서울 U+ 스테이지", "연극 애나엑스", + "LG아트센터 서울 U+ 스테이지", listOf( listOf("2025-01-28T16:00:00", "2025-01-28T18:00:00"), listOf("2025-03-16T16:00:00", "2025-03-16T18:00:00") ) ), Triple( - "LG아트센터 서울 U+ 스테이지", "연극 타인의 삶", + "LG아트센터 서울 U+ 스테이지", listOf( listOf("2025-01-28T16:00:00", "2025-01-28T18:00:00"), listOf("2025-03-16T16:00:00", "2025-03-16T18:00:00") ) ), Triple( - "세종문화회관 M씨어터", "세일즈맨의 죽음", + "세종문화회관 M씨어터", listOf( listOf("2025-01-07T16:00:00", "2025-01-07T18:00:00"), listOf("2025-03-03T16:00:00", "2025-03-03T18:00:00") diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt index 931f065..d8f0be8 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt @@ -54,8 +54,8 @@ typealias GetPerformanceEventResponse = List data class CreatePerformanceEventRequest( val performanceId: String, val performanceHallId: String, - val startAt: Instant, - val endAt: Instant, + val startAt: String, + val endAt: String, ) typealias CreatePerformanceEventResponse = PerformanceEvent \ No newline at end of file diff --git a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt index 3e6499c..283f1de 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt @@ -12,7 +12,8 @@ import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* import org.springframework.transaction.annotation.Transactional -import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId import java.util.UUID @AutoConfigureMockMvc @@ -85,7 +86,7 @@ constructor( } performanceId = mvc.perform( - get("/v1/performance/search") + get("/api/v1/performance/search") ).andExpect(status().`is`(200)) .andReturn() .response @@ -102,8 +103,8 @@ constructor( mapOf( "performanceId" to performanceId, "performanceHallId" to performanceHallId, - "startAt" to Instant.now(), - "endAt" to Instant.now(), + "startAt" to LocalDateTime.now(ZoneId.of("Asia/Seoul")), + "endAt" to LocalDateTime.now(ZoneId.of("Asia/Seoul")) ), ), ) From ae829528cc313687cef38efefb3715f9077b0829 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Mon, 13 Jan 2025 12:04:23 +0900 Subject: [PATCH 033/162] add PerformanceDetail Uri --- .../interpark/config/DataInitializer.kt | 24 +-- .../interpark/PerformanceIntegrationTest.kt | 164 ++++++++++++++++++ 2 files changed, 176 insertions(+), 12 deletions(-) create mode 100644 src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index 1f3dcfe..ca12d59 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -76,84 +76,84 @@ class DataInitializer( // 2) Performance 데이터 넣기 performanceService.createPerformance( title = "뮤지컬 지킬앤하이드", - detail = "지금 이 순간, 끝나지 않는 신화", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24013928-21.jpg", category = PerformanceCategory.MUSICAL, posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24013928_p.gif", backdropImageUri = "http://example.com/backdrop/jekyll.jpg" ) performanceService.createPerformance( title = "마타하리", - detail = "She's BACK!", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/L0000106-08.jpg", category = PerformanceCategory.MUSICAL, posterUri = "https://ticketimage.interpark.com/Play/image/large/L0/L0000106_p.gif", backdropImageUri = "http://example.com/backdrop/phantom.jpg" ) performanceService.createPerformance( title = "웃는남자", - detail = "부자들의 낙원은 가난한 자들의 지옥으로 세워진 것이다", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24016737-04.jpg", category = PerformanceCategory.MUSICAL, posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24016737_p.gif", backdropImageUri = "http://example.com/backdrop/mom.jpg" ) performanceService.createPerformance( title = "2025 기리보이 콘서트", - detail = "2252:2522", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24018543-01.jpg", category = PerformanceCategory.CONCERT, posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24018543_p.gif", backdropImageUri = "http://example.com/backdrop/mom.jpg" ) performanceService.createPerformance( title = "2025 검정치마 단독공연", - detail = "SONGS TO BRING YOU HOME", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000084-01.jpg", category = PerformanceCategory.CONCERT, posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000084_p.gif", backdropImageUri = "http://example.com/backdrop/mom.jpg" ) performanceService.createPerformance( title = "콜드플레이 내한공연", - detail = "MUSIC of the SPHERES", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24013437-06.jpg", category = PerformanceCategory.CONCERT, posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24013437_p.gif", backdropImageUri = "http://example.com/backdrop/mom.jpg" ) performanceService.createPerformance( title = "브루스 리우 피아노 리사이틀", - detail = "TCHAIKOVSKY | MENDELSSOHN | SCRIABIN | PROKOFIEV", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24016119-01.jpg", category = PerformanceCategory.CLASSIC, posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24016119_p.gif", backdropImageUri = "http://example.com/backdrop/mom.jpg" ) performanceService.createPerformance( title = "크리스티안 테츨라프 바이올린 리사이틀", - detail = "SUK | BRAHMS | SZYMANOWSKI | FRANCK", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24015137-01.jpg", category = PerformanceCategory.CLASSIC, posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24015137_p.gif", backdropImageUri = "http://example.com/backdrop/mom.jpg" ) performanceService.createPerformance( title = "발레의 별빛, 글로벌 발레스타 초청 갈라공연", - detail = "전 세계가 먼저 찾는 한국 스타 무용수들의 향연!", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/P0004046-06.jpg", category = PerformanceCategory.CLASSIC, posterUri = "https://ticketimage.interpark.com/Play/image/large/P0/P0004046_p.gif", backdropImageUri = "http://example.com/backdrop/mom.jpg" ) performanceService.createPerformance( title = "연극 애나엑스", - detail = "ANNA X", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/L0000107-02.jpg", category = PerformanceCategory.PLAY, posterUri = "https://ticketimage.interpark.com/Play/image/large/L0/L0000107_p.gif", backdropImageUri = "http://example.com/backdrop/mom.jpg" ) performanceService.createPerformance( title = "연극 타인의 삶", - detail = "영화 타인의 삶 원작", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/L0000104-05.jpg", category = PerformanceCategory.PLAY, posterUri = "https://ticketimage.interpark.com/Play/image/large/L0/L0000104_p.gif", backdropImageUri = "http://example.com/backdrop/mom.jpg" ) performanceService.createPerformance( title = "세일즈맨의 죽음", - detail = "현 희곡의 거장 '아서 밀러'의 대표작 연극<세일즈맨의 죽음>이 돌아왔다!", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24017573-06.jpg", category = PerformanceCategory.PLAY, posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24017573_p.gif", backdropImageUri = "http://example.com/backdrop/mom.jpg" diff --git a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt new file mode 100644 index 0000000..8e774d8 --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt @@ -0,0 +1,164 @@ +package com.wafflestudio.interpark + +import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.interpark.performance.persistence.PerformanceCategory +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Disabled("Temporarily disabled for build process") +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Transactional +class PerformanceServiceTest +@Autowired +constructor( + private val mvc: MockMvc, + private val mapper: ObjectMapper, +) { + private lateinit var accessToken: String + private lateinit var performanceId: String + + @BeforeEach + fun setUp() { + val username = UUID.randomUUID().toString().take(8) + val password = "password123" + + // 1️⃣ 회원가입 + mvc.perform( + post("/api/v1/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + "nickname" to "test_user", + "phoneNumber" to "010-0000-0000", + "email" to "test@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + // 2️⃣ 로그인 → 토큰 획득 + accessToken = + mvc.perform( + post("/api/v1/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + // 3️⃣ 공연 생성 + performanceId = + mvc.perform( + post("/admin/v1/performance") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf( + "title" to "뮤지컬 지킬앤하이드", + "detail" to "지금 이 순간, 끝나지 않는 신화", + "category" to PerformanceCategory.MUSICAL.name, + "posterUri" to "https://example.com/poster.jpg", + "backdropImageUri" to "https://example.com/backdrop.jpg", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("id").asText() } + } + + @Test + fun `공연 검색 플로우 테스트`() { + // 4️⃣ 공연 검색 (title 조건) + mvc.perform( + get("/api/v1/performance/search") + .header("Authorization", "Bearer $accessToken") + .param("title", "지킬앤하이드") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[0].title").value("뮤지컬 지킬앤하이드")) + + // 5️⃣ 공연 검색 (category 조건) + mvc.perform( + get("/api/v1/performance/search") + .header("Authorization", "Bearer $accessToken") + .param("category", PerformanceCategory.MUSICAL.name) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[0].category").value(PerformanceCategory.MUSICAL.name)) + } + + @Test + fun `공연 상세 조회 테스트`() { + // 6️⃣ 공연 상세 조회 + mvc.perform( + get("/api/v1/performance/$performanceId") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$.id").value(performanceId)) + .andExpect(jsonPath("$.title").value("뮤지컬 지킬앤하이드")) + } + + @Test + fun `공연 삭제 플로우 테스트`() { + // 7️⃣ 공연 삭제 + mvc.perform( + delete("/admin/v1/performance/$performanceId") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(204)) + + // 8️⃣ 삭제된 공연 상세 조회 실패 확인 + mvc.perform( + get("/api/v1/performance/$performanceId") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(404)) + .andExpect(jsonPath("$.error").value("Performance Not Found")) + } + + @Test + fun `공연 생성 실패 - 필수 정보 누락`() { + mvc.perform( + post("/admin/v1/performance") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf( + "title" to "", + "detail" to "지금 이 순간, 끝나지 않는 신화", + "category" to PerformanceCategory.MUSICAL.name, + "posterUri" to "", + "backdropImageUri" to "", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(400)) + .andExpect(jsonPath("$.error").value("Invalid Performance Data")) + } +} \ No newline at end of file From 8f20bdd274675c22da0ca5842e46f2deaa8d9fa4 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Tue, 14 Jan 2025 02:47:31 +0900 Subject: [PATCH 034/162] make PerformanceItegrationTest.kt --- build.gradle.kts | 1 + .../interpark/GlobalExceptionHandler.kt | 14 ++++++++++++++ .../performance/PerformanceEventException.kt | 2 +- .../performance/PerformanceException.kt | 2 +- .../performance/PerformanceHallException.kt | 2 +- .../controller/PerformanceController.kt | 17 +++++++++++++---- .../controller/PerformanceEventController.kt | 7 +++---- .../controller/PerformanceHallController.kt | 7 ++++--- .../performance/service/PerformanceService.kt | 4 +++- .../interpark/PerformanceIntegrationTest.kt | 5 ++--- 10 files changed, 43 insertions(+), 18 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 88585e6..2ac73e0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,6 +20,7 @@ repositories { dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.mindrot:jbcrypt:0.4") diff --git a/src/main/kotlin/com/wafflestudio/interpark/GlobalExceptionHandler.kt b/src/main/kotlin/com/wafflestudio/interpark/GlobalExceptionHandler.kt index 88227d6..73195c1 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/GlobalExceptionHandler.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/GlobalExceptionHandler.kt @@ -1,6 +1,7 @@ package com.wafflestudio.interpark import org.springframework.http.ResponseEntity +import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ControllerAdvice import org.springframework.web.bind.annotation.ExceptionHandler @@ -12,4 +13,17 @@ class GlobalExceptionHandler { .status(exception.httpErrorCode) .body(mapOf("error" to exception.msg, "errorCode" to exception.errorCode)) } + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidationExceptions(exeption: MethodArgumentNotValidException): ResponseEntity> { + val errors = exeption.bindingResult.fieldErrors.associate { + it.field to (it.defaultMessage ?: "Invalid value") + } + return ResponseEntity.badRequest().body( + mapOf( + "error" to "Method Argument Validation failed", + "details" to errors + ) + ) + } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceEventException.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceEventException.kt index 546319d..415e6fb 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceEventException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceEventException.kt @@ -13,6 +13,6 @@ sealed class PerformanceEventException( class PerformanceEventNotFoundException : PerformanceEventException( errorCode = 0, - httpStatusCode = HttpStatus.BAD_REQUEST, + httpStatusCode = HttpStatus.NOT_FOUND, msg = "PerformanceEvent Not Found", ) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceException.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceException.kt index 9def87d..cc5d544 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceException.kt @@ -13,6 +13,6 @@ sealed class PerformanceException( class PerformanceNotFoundException : PerformanceException( errorCode = 0, - httpStatusCode = HttpStatus.BAD_REQUEST, + httpStatusCode = HttpStatus.NOT_FOUND, msg = "Performance Not Found", ) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceHallException.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceHallException.kt index 945bb9f..fc96b96 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceHallException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceHallException.kt @@ -13,6 +13,6 @@ sealed class PerformanceHallException( class PerformanceHallNotFoundException : PerformanceHallException( errorCode = 0, - httpStatusCode = HttpStatus.BAD_REQUEST, + httpStatusCode = HttpStatus.NOT_FOUND, msg = "PerformanceHall Not Found", ) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index cf18202..d1a8711 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -3,6 +3,10 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import io.swagger.v3.oas.annotations.Operation import com.wafflestudio.interpark.performance.service.PerformanceService +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @@ -22,12 +26,12 @@ class PerformanceController( val queriedPerformances = performanceService.searchPerformance(title, category) return ResponseEntity.ok(queriedPerformances) } - + // WARN: THIS IS FOR ADMIN. // TODO: SEPERATE THIS TO OTHER APPLICATION @PostMapping("/admin/v1/performance") fun createPerformance( - @RequestBody request: CreatePerformanceRequest, + @Valid @RequestBody request: CreatePerformanceRequest, ): ResponseEntity { val newPerformance: Performance = performanceService @@ -38,7 +42,7 @@ class PerformanceController( request.posterUri, request.backdropImageUri ) - return ResponseEntity.ok(newPerformance) + return ResponseEntity.status(HttpStatus.CREATED).body(newPerformance) } @GetMapping("/api/v1/performance/{performanceId}") @@ -66,11 +70,16 @@ class PerformanceController( typealias SearchPerformanceResponse = List data class CreatePerformanceRequest( + @field:NotBlank(message = "Title must not be blank") val title: String, + @field:NotBlank(message = "Detail must not be blank") val detail: String, + @field:NotNull(message = "Category must not be null") val category: PerformanceCategory, + @field:NotBlank(message = "Poster URI must not be blank") val posterUri: String, - val backdropImageUri: String, + @field:NotBlank(message = "Backdrop Image URI must not be blank") + val backdropImageUri: String ) typealias CreatePerformanceResponse = Performance diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt index d8f0be8..99870bc 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt @@ -3,11 +3,10 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.service.PerformanceEventService import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.AuthUser +import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* -import java.time.Instant - @RestController class PerformanceEventController( private val performanceEventService: PerformanceEventService, @@ -25,7 +24,7 @@ class PerformanceEventController( // WARN: THIS IS FOR ADMIN. // TODO: SEPERATE THIS TO OTHER APPLICATION @PostMapping("/admin/v1/performance-event") - fun createPerformance( + fun createPerformanceEvent( @RequestBody request: CreatePerformanceEventRequest, ): ResponseEntity { val newPerformanceEvent: PerformanceEvent = @@ -36,7 +35,7 @@ class PerformanceEventController( request.startAt, request.endAt ); - return ResponseEntity.ok(newPerformanceEvent) + return ResponseEntity.status(HttpStatus.CREATED).body(newPerformanceEvent) } @DeleteMapping("/admin/v1/performance-event/{performanceEventId}") diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt index d7b2913..b2dd2c3 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt @@ -3,6 +3,7 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.service.PerformanceHallService import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.AuthUser +import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @@ -11,7 +12,7 @@ class PerformanceHallController( private val performanceHallService: PerformanceHallService, ) { @GetMapping("/api/v1/performance-hall") - fun getPerformance( + fun getPerformanceHall( @AuthUser user: User, ): ResponseEntity { // Currently, no search @@ -23,7 +24,7 @@ class PerformanceHallController( // WARN: THIS IS FOR ADMIN. // TODO: SEPERATE THIS TO OTHER APPLICATION @PostMapping("/admin/v1/performance-hall") - fun createPerformance( + fun createPerformanceHall( @RequestBody request: CreatePerformanceHallRequest, ): ResponseEntity { val newPerformanceHall: PerformanceHall = @@ -33,7 +34,7 @@ class PerformanceHallController( request.address, request.maxAudience, ); - return ResponseEntity.ok(newPerformanceHall) + return ResponseEntity.status(HttpStatus.CREATED).body(newPerformanceHall) } @DeleteMapping("/admin/v1/performance-hall/{performanceHallId}") diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt index 59a64d8..2b03df7 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt @@ -74,7 +74,9 @@ class PerformanceService( val performanceEntity: PerformanceEntity = performanceRepository.findByIdOrNull(performanceId) ?: throw PerformanceNotFoundException() val performanceEventEntities = performanceEventRepository.findAllByPerformanceId(performanceEntity.id!!) val performanceEvents = performanceEventEntities.map{ PerformanceEvent.fromEntity(it) } - val performanceHall = PerformanceHall.fromEntity(performanceEventEntities.first().performanceHall) + val performanceHall = performanceEventEntities.firstOrNull()?.let { + PerformanceHall.fromEntity(it.performanceHall) + } return Performance.fromEntity(performanceEntity, performanceEvents, performanceHall) } diff --git a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt index 8e774d8..cf3a9d3 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt @@ -3,7 +3,6 @@ package com.wafflestudio.interpark import com.fasterxml.jackson.databind.ObjectMapper import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc @@ -15,7 +14,6 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* import org.springframework.transaction.annotation.Transactional import java.util.UUID -@Disabled("Temporarily disabled for build process") @AutoConfigureMockMvc @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Transactional @@ -117,6 +115,7 @@ constructor( @Test fun `공연 상세 조회 테스트`() { // 6️⃣ 공연 상세 조회 + println("Generated performanceId: $performanceId") mvc.perform( get("/api/v1/performance/$performanceId") .header("Authorization", "Bearer $accessToken"), @@ -159,6 +158,6 @@ constructor( ) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().`is`(400)) - .andExpect(jsonPath("$.error").value("Invalid Performance Data")) + .andExpect(jsonPath("$.error").value("Method Argument Validation failed")) } } \ No newline at end of file From 039623ff1c74f3823eb072f440039bee5917b179 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Tue, 14 Jan 2025 22:26:04 +0900 Subject: [PATCH 035/162] =?UTF-8?q?feat:=20=EC=98=88=EB=A7=A4=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 동시성 처리 완료 동시성 처리는 잘 되지만, 테스트 코드 작성이 많이 까다로움 --- .../seat/persistence/ReservationRepository.kt | 7 + .../interpark/seat/service/SeatService.kt | 8 +- .../interpark/SeatIntegrationTest.kt | 4 +- .../interpark/SimultaneousTest.kt | 168 ++++++++++++++++++ 4 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt index 5912ebf..47130a2 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt @@ -1,9 +1,16 @@ package com.wafflestudio.interpark.seat.persistence +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 interface ReservationRepository : JpaRepository { fun findByUserId(userId: String): List fun findByPerformanceEventIdAndReservedIsFalse(performanceEventId: String): List + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM ReservationEntity r WHERE r.id = :id") + fun findByIdWithWriteLock(id: String): ReservationEntity? } diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt index fe896dd..156d385 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt @@ -1,6 +1,7 @@ package com.wafflestudio.interpark.seat.service import com.wafflestudio.interpark.seat.ReservationNotFoundException +import com.wafflestudio.interpark.seat.ReservationPermissionDeniedException import com.wafflestudio.interpark.seat.ReservedAlreadyException import com.wafflestudio.interpark.seat.ReservedYetException import com.wafflestudio.interpark.seat.controller.Reservation @@ -33,9 +34,8 @@ class SeatService( user: User, reservationId: String, ): String { - // TODO: 동시성 처리하기 val targetUser = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() - val targetReservation = reservationRepository.findByIdOrNull(reservationId) ?: throw ReservationNotFoundException() + val targetReservation = reservationRepository.findByIdWithWriteLock(reservationId) ?: throw ReservationNotFoundException() if (targetReservation.reserved) throw ReservedAlreadyException() @@ -56,7 +56,7 @@ class SeatService( val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() val reservationUser = reservationEntity.user ?: throw ReservedYetException() if (reservationUser.id != userEntity.id) { - throw AuthenticateException() + throw ReservationPermissionDeniedException() } val seatEntity = reservationEntity.seat @@ -84,7 +84,7 @@ class SeatService( val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() val reservationUser = reservationEntity.user ?: throw ReservedYetException() if (reservationUser.id != userEntity.id) { - throw AuthenticateException() + throw ReservationPermissionDeniedException() } reservationEntity.user = null diff --git a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt index 3e6499c..d727e26 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt @@ -135,7 +135,7 @@ constructor( } @Test - fun `좌석을 예매할 수 있다`() { + fun `좌석을 예매할 수 있고 예매되면 더 이상 예매되지 못한다`() { val reservationId = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) @@ -297,6 +297,6 @@ constructor( ) .header("Authorization", "Bearer $otherAccessToken") .contentType(MediaType.APPLICATION_JSON) - ).andExpect(status().`is`(401)) + ).andExpect(status().`is`(403)) } } \ No newline at end of file diff --git a/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt new file mode 100644 index 0000000..1ca16d7 --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt @@ -0,0 +1,168 @@ +package com.wafflestudio.interpark + +import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.interpark.seat.service.SeatCreationService +import com.wafflestudio.interpark.user.persistence.UserRepository +import com.wafflestudio.interpark.user.service.UserService +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import java.time.Instant +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger + +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class SimultaneousTest +@Autowired +constructor( + private val mvc: MockMvc, + private val mapper: ObjectMapper, + private val seatCreationService: SeatCreationService, + private val userRepository: UserRepository, +) { + @Test + fun `한 예매에 동시에 여러 접근이 있다면 하나만 통과시킨다`() { + val threadPool = Executors.newFixedThreadPool(10) + val username = UUID.randomUUID().toString().take(8) + val password = "password123" + + // 1️⃣ 회원가입 + mvc.perform( + post("/api/v1/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + "nickname" to "reviewer", + "phoneNumber" to "010-0000-0000", + "email" to "reviewer@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + // 2️⃣ 로그인 → 토큰 획득 + val accessToken = + mvc.perform( + post("/api/v1/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + //Seat와 Reservation 만들기 위한 EventId 만들기 + val performanceHallId = + mvc.perform( + get("/api/v1/performance-hall") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performanceHalls = mapper.readTree(it) + performanceHalls[0].get("id").asText() + } + val performanceId = + mvc.perform( + get("/v1/performance/search") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performances = mapper.readTree(it) + performances[0].get("id").asText() + } + + mvc.perform( + post("/admin/v1/performance-event") + .content( + mapper.writeValueAsString( + mapOf( + "performanceId" to performanceId, + "performanceHallId" to performanceHallId, + "startAt" to Instant.now(), + "endAt" to Instant.now(), + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON) + ) + + val performanceEventId = + mvc.perform( + get("/api/v1/performance-event") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performanceEvents = mapper.readTree(it) + performanceEvents[0].get("id").asText() + } + //Seat와 Reservation만들기 + seatCreationService.createSeats(performanceHallId, "DEFAULT") + seatCreationService.createEmptyReservations(performanceEventId) + + val reservationId = mvc.perform( + get("/api/v1/seat/$performanceEventId/available") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val availableSeats = mapper.readTree(it).get("availableSeats") + availableSeats[0].get("reservationId").asText() + } + + val results = mutableListOf() + var successCnt = AtomicInteger(0) + var conflictCnt = AtomicInteger(0) + val tasks = (1..10).map { + threadPool.submit { + val responseStatus = mvc.perform( + post("/api/v1/reservation/reserve") + .content( + mapper.writeValueAsString( + mapOf( + "reservationId" to reservationId, + ), + ), + ) + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + ).andReturn().response.status + results.add(responseStatus) + if (responseStatus == 200) { successCnt.incrementAndGet() } + if (responseStatus == 409) { conflictCnt.incrementAndGet() } + } + } + tasks.forEach { it.get() } + assert(successCnt.get() == 1) {"expected 1 success but ${successCnt.get()}"} + assert(conflictCnt.get() == 9) {"expected 9 conflict but ${conflictCnt.get()}"} + } +} \ No newline at end of file From d5f24faf21a34b7b67b0e4a0d82845e440f0b64a Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Tue, 14 Jan 2025 22:32:06 +0900 Subject: [PATCH 036/162] feat: Get My Reservation Api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 유저가 로그인한채로 본인의 예약 목록을 확인할 수 있는 기능 추가 --- .../interpark/seat/controller/SeatController.kt | 12 ++++++++++++ .../interpark/seat/service/SeatService.kt | 8 ++++++++ 2 files changed, 20 insertions(+) diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt index 0f1d8d0..7193b43 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt @@ -31,6 +31,14 @@ class SeatController( return ResponseEntity.status(200).body(ReserveSeatResponse(reservationId)) } + @GetMapping("/api/v1/reservation/me") + fun getMyReservations( + @AuthUser user: User, + ): ResponseEntity { + val reservationIds = seatService.getMyReservations(user) + return ResponseEntity.ok(GetMyReservationsResponse(reservationIds)) + } + @GetMapping("/api/v1/reservation/detail/{reservationId}") fun getReservedSeatDetail( @PathVariable reservationId: String, @@ -67,6 +75,10 @@ data class ReserveSeatResponse( val reservationId: String, ) +data class GetMyReservationsResponse( + val reservationIds: List, +) + data class GetReservedSeatDetailResponse( val reservedSeat: Reservation ) diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt index 156d385..b08adab 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt @@ -47,6 +47,14 @@ class SeatService( return reservationId } + @Transactional + fun getMyReservations(user: User): List { + userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() + val myReservations = reservationRepository.findByUserId(user.id) + + return myReservations.map { it.id!! } + } + @Transactional fun getReservedSeatDetail( user: User, From 841136b4318d7f63972e4a086bce3af3eaf73e6b Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Tue, 14 Jan 2025 23:27:26 +0900 Subject: [PATCH 037/162] feat: Seat Get TestCode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Get API가 정상작동하는지 테스트코드 추가 --- .../seat/controller/SeatController.kt | 8 +-- .../interpark/SeatIntegrationTest.kt | 70 +++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt index 7193b43..13818c2 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt @@ -31,12 +31,12 @@ class SeatController( return ResponseEntity.status(200).body(ReserveSeatResponse(reservationId)) } - @GetMapping("/api/v1/reservation/me") + @GetMapping("/api/v1/me/reservation") fun getMyReservations( @AuthUser user: User, ): ResponseEntity { - val reservationIds = seatService.getMyReservations(user) - return ResponseEntity.ok(GetMyReservationsResponse(reservationIds)) + val myReservationIds = seatService.getMyReservations(user) + return ResponseEntity.ok(GetMyReservationsResponse(myReservationIds)) } @GetMapping("/api/v1/reservation/detail/{reservationId}") @@ -76,7 +76,7 @@ data class ReserveSeatResponse( ) data class GetMyReservationsResponse( - val reservationIds: List, + val myReservationIds: List, ) data class GetReservedSeatDetailResponse( diff --git a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt index d727e26..7daa693 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt @@ -173,6 +173,76 @@ constructor( ).andExpect(status().`is`(409)) } + @Test + fun `본인의 예매내역을 확인할 수 있다`() { + val reservationId = mvc.perform( + get("/api/v1/seat/$performanceEventId/available") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val availableSeats = mapper.readTree(it).get("availableSeats") + availableSeats[0].get("reservationId").asText() + } + mvc.perform( + post("/api/v1/reservation/reserve") + .content( + mapper.writeValueAsString( + mapOf( + "reservationId" to reservationId, + ), + ), + ) + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + ).andExpect(status().`is`(200)) + + val myReservationIds = mvc.perform( + get("/api/v1/me/reservation") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + mapper.readTree(it).get("myReservationIds") + } + assert(myReservationIds.size() == 1) {"Expected size 1 but ${myReservationIds.size()}"} + assert(myReservationIds[0].asText() == reservationId) {"Expected $reservationId but ${myReservationIds[0].asText()}"} + } + + @Test + fun `본인의 예매를 자세히 볼 수 있다`() { + val reservationId = mvc.perform( + get("/api/v1/seat/$performanceEventId/available") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val availableSeats = mapper.readTree(it).get("availableSeats") + availableSeats[0].get("reservationId").asText() + } + mvc.perform( + post("/api/v1/reservation/reserve") + .content( + mapper.writeValueAsString( + mapOf( + "reservationId" to reservationId, + ), + ), + ) + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + ).andExpect(status().`is`(200)) + + val myReservationIds = mvc.perform( + get("/api/v1/reservation/detail/$reservationId") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(200)) + } + @Test fun `좌석을 취소할 수 있다`() { val reservationId = mvc.perform( From 5d976125bdda10212c8bc2fe46f375861dce2bdd Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Thu, 16 Jan 2025 17:22:47 +0900 Subject: [PATCH 038/162] modify SearchPerformanceResponse --- .../performance/controller/Performance.kt | 25 ++++++- .../controller/PerformanceController.kt | 13 +++- .../performance/service/PerformanceService.kt | 9 +-- .../interpark/PerformanceIntegrationTest.kt | 67 +++++++++++++------ .../interpark/ReplyIntegrationTest.kt | 2 +- 5 files changed, 90 insertions(+), 26 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt index 75a0bf8..79b1781 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt @@ -3,6 +3,7 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import com.wafflestudio.interpark.performance.persistence.PerformanceEntity import java.time.LocalDate +import java.time.ZoneId data class Performance( val id: String, @@ -28,7 +29,7 @@ data class Performance( title = performanceEntity.title, hallName = performanceHall?.name ?: "", performanceDates = performanceEvents?.map { - it.startAt.atZone(java.time.ZoneId.of("Asia/Seoul")).toLocalDate() + it.startAt.atZone(ZoneId.of("Asia/Seoul")).toLocalDate() }?.distinct() ?: emptyList(), detail = performanceEntity.detail, category = performanceEntity.category, @@ -36,5 +37,27 @@ data class Performance( backdropImageUri = performanceEntity.backdropImageUri ) } + + fun fromEntityToBriefDetails( + performanceEntity: PerformanceEntity, + performanceEvents: List?, + performanceHall: PerformanceHall?, + ): BriefPerformanceDetail { + return BriefPerformanceDetail( + id = performanceEntity.id!!, + title = performanceEntity.title, + hallName = performanceHall?.name ?: "", + performanceDuration = if (!performanceEvents.isNullOrEmpty()) { + val seoulZone = ZoneId.of("Asia/Seoul") + + val minDate = performanceEvents.minOf { it.startAt }.atZone(seoulZone).toLocalDate() + val maxDate = performanceEvents.maxOf { it.startAt }.atZone(seoulZone).toLocalDate() + + Pair(minDate, maxDate) + } else { + null + } + ) + } } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index d1a8711..4f1afc3 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -9,6 +9,7 @@ import jakarta.validation.constraints.NotNull import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* +import java.time.LocalDate @RestController class PerformanceController( @@ -67,7 +68,17 @@ class PerformanceController( } -typealias SearchPerformanceResponse = List +typealias SearchPerformanceResponse = List + +data class BriefPerformanceDetail( + val id: String, + val title: String, + val hallName: String, + val performanceDuration: Pair?, + // 추후 제공 예정 + // val ratingAvg: Double, + // val reviewCount: Int, +) data class CreatePerformanceRequest( @field:NotBlank(message = "Title must not be blank") diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt index 2b03df7..029ad82 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt @@ -1,6 +1,7 @@ package com.wafflestudio.interpark.performance.service import com.wafflestudio.interpark.performance.PerformanceNotFoundException +import com.wafflestudio.interpark.performance.controller.BriefPerformanceDetail import com.wafflestudio.interpark.performance.controller.Performance import com.wafflestudio.interpark.performance.controller.PerformanceEvent import com.wafflestudio.interpark.performance.controller.PerformanceHall @@ -17,7 +18,7 @@ class PerformanceService( fun searchPerformance( title: String?, category: PerformanceCategory?, - ): List { + ): List { // 시작점: 아무 조건이 없는 스펙 var spec: Specification = Specification.where(null) @@ -34,7 +35,7 @@ class PerformanceService( // 스펙이 결국 아무 조건도 없으면 -> 전체 검색 val performanceEntities = performanceRepository.findAll(spec) - // DTO 변환 + // BriefDetail DTO 변환 return performanceEntities.map { performanceEntity -> val performanceEventEntities = performanceEventRepository.findAllByPerformanceId(performanceEntity.id!!) val performanceEvents = if (performanceEventEntities.isEmpty()) { @@ -46,7 +47,7 @@ class PerformanceService( PerformanceHall.fromEntity(it.performanceHall) } - Performance.fromEntity( + Performance.fromEntityToBriefDetails( performanceEntity = performanceEntity, performanceHall = performanceHall, performanceEvents = performanceEvents @@ -67,7 +68,7 @@ class PerformanceService( performanceHall = performanceHall, performanceEvents = performanceEvents ) - }; + } } fun getPerformanceDetail(performanceId: String): Performance { diff --git a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt index cf3a9d3..e419349 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt @@ -2,11 +2,14 @@ package com.wafflestudio.interpark import com.fasterxml.jackson.databind.ObjectMapper import com.wafflestudio.interpark.performance.persistence.PerformanceCategory +import com.wafflestudio.interpark.performance.persistence.PerformanceRepository +import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* @@ -17,7 +20,7 @@ import java.util.UUID @AutoConfigureMockMvc @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Transactional -class PerformanceServiceTest +class PerformanceIntegrationTest @Autowired constructor( private val mvc: MockMvc, @@ -67,28 +70,24 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it).get("accessToken").asText() } - // 3️⃣ 공연 생성 + // 3️⃣ 테스트용 공연 ID 반환 performanceId = mvc.perform( - post("/admin/v1/performance") + get("/api/v1/performance/search") .header("Authorization", "Bearer $accessToken") - .content( - mapper.writeValueAsString( - mapOf( - "title" to "뮤지컬 지킬앤하이드", - "detail" to "지금 이 순간, 끝나지 않는 신화", - "category" to PerformanceCategory.MUSICAL.name, - "posterUri" to "https://example.com/poster.jpg", - "backdropImageUri" to "https://example.com/backdrop.jpg", - ), - ), - ) + .param("title", "지킬앤하이드") .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(201)) + ).andExpect(status().`is`(200)) .andReturn() .response .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it).get("id").asText() } + .let { + val node = mapper.readTree(it) + val firstItem = node.firstOrNull() ?: error("Response array is empty") + val idNode = firstItem.get("id") + requireNotNull(idNode) { "ID not found in response item: $firstItem" } + idNode.asText() + } } @Test @@ -101,15 +100,17 @@ constructor( .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().`is`(200)) .andExpect(jsonPath("$[0].title").value("뮤지컬 지킬앤하이드")) + .andExpect(jsonPath("$.length()").value(1)) // 5️⃣ 공연 검색 (category 조건) mvc.perform( get("/api/v1/performance/search") .header("Authorization", "Bearer $accessToken") - .param("category", PerformanceCategory.MUSICAL.name) + .param("category", PerformanceCategory.CONCERT.name) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().`is`(200)) - .andExpect(jsonPath("$[0].category").value(PerformanceCategory.MUSICAL.name)) + .andExpect(jsonPath("$").isArray) // 응답이 배열인지 확인 + .andExpect(jsonPath("$.length()").value(3)) // 배열의 길이가 0인지 확인 } @Test @@ -140,6 +141,33 @@ constructor( .andExpect(jsonPath("$.error").value("Performance Not Found")) } + @Test + fun `공연 생성 테스트`() { + // 1️⃣ 공연 생성 요청 데이터 + val createPerformanceRequest = mapOf( + "title" to "뮤지컬 캣츠", + "detail" to "https://example.com/cats-detail.jpg", + "category" to PerformanceCategory.MUSICAL.name, + "posterUri" to "https://example.com/cats-poster.jpg", + "backdropImageUri" to "https://example.com/cats-backdrop.jpg" + ) + + // 2️⃣ 공연 생성 요청 및 응답 확인 + val result = mvc.perform( + post("/admin/v1/performance") + .header("Authorization", "Bearer $accessToken") + .content(mapper.writeValueAsString(createPerformanceRequest)) + .contentType(MediaType.APPLICATION_JSON), + ) + .andExpect(status().`is`(201)) // HTTP 201 Created 확인 + .andExpect(jsonPath("$.title").value("뮤지컬 캣츠")) + .andExpect(jsonPath("$.detail").value("https://example.com/cats-detail.jpg")) + .andExpect(jsonPath("$.category").value(PerformanceCategory.MUSICAL.name)) + .andExpect(jsonPath("$.posterUri").value("https://example.com/cats-poster.jpg")) + .andExpect(jsonPath("$.backdropImageUri").value("https://example.com/cats-backdrop.jpg")) + .andReturn() + } + @Test fun `공연 생성 실패 - 필수 정보 누락`() { mvc.perform( @@ -160,4 +188,5 @@ constructor( ).andExpect(status().`is`(400)) .andExpect(jsonPath("$.error").value("Method Argument Validation failed")) } -} \ No newline at end of file +} + \ No newline at end of file diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt index 476c4da..92ad67d 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt @@ -16,7 +16,7 @@ import java.util.UUID @AutoConfigureMockMvc @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Transactional -class ReplyControllerTest +class ReplyIntegrationTest @Autowired constructor( private val mvc: MockMvc, From a401a9b399ffdabaa53af17248df4e00e6e58207 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Thu, 16 Jan 2025 19:22:33 +0900 Subject: [PATCH 039/162] check auth for create, delete performance --- .../controller/PerformanceController.kt | 25 ++++- .../user/controller/UserController.kt | 13 ++- .../user/persistence/UserIdentityEntity.kt | 6 +- .../persistence/UserIdentityRepository.kt | 1 + .../interpark/user/service/UserService.kt | 8 +- .../interpark/PerformanceIntegrationTest.kt | 91 ++++++++++++++++--- 6 files changed, 125 insertions(+), 19 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index 4f1afc3..9376bc3 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -3,6 +3,10 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import io.swagger.v3.oas.annotations.Operation import com.wafflestudio.interpark.performance.service.PerformanceService +import com.wafflestudio.interpark.user.AuthUser +import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.persistence.UserRole +import com.wafflestudio.interpark.user.service.UserService import jakarta.validation.Valid import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull @@ -14,6 +18,7 @@ import java.time.LocalDate @RestController class PerformanceController( private val performanceService: PerformanceService, + private val userService: UserService, ) { @GetMapping("/api/v1/performance/search") @Operation( @@ -33,7 +38,16 @@ class PerformanceController( @PostMapping("/admin/v1/performance") fun createPerformance( @Valid @RequestBody request: CreatePerformanceRequest, + @AuthUser user: User, ): ResponseEntity { + // UserIdentity를 통해 역할(Role) 확인 + val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 + ?: return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) // UserIdentity가 없으면 FORBIDDEN 반환 + + if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) + } + val newPerformance: Performance = performanceService .createPerformance( @@ -60,8 +74,17 @@ class PerformanceController( @DeleteMapping("/admin/v1/performance/{performanceId}") fun deletePerformance( - @PathVariable performanceId: String + @PathVariable performanceId: String, + @AuthUser user: User, ): ResponseEntity { + // UserIdentity를 통해 역할(Role) 확인 + val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 + ?: return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) // UserIdentity가 없으면 FORBIDDEN 반환 + + if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) + } + performanceService.deletePerformance(performanceId) return ResponseEntity.noContent().build() } diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt index 9c8fc33..a388b1d 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt @@ -1,6 +1,7 @@ package com.wafflestudio.interpark.user.controller import com.wafflestudio.interpark.user.* +import com.wafflestudio.interpark.user.persistence.UserRole import com.wafflestudio.interpark.user.service.UserService import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.media.Content @@ -88,11 +89,12 @@ class UserController( ): ResponseEntity { val user = userService.signUp( - request.username, - request.password, - request.nickname, - request.email, - request.phoneNumber, + username = request.username, + password = request.password, + nickname = request.nickname, + email = request.email, + phoneNumber = request.phoneNumber, + role = request.role, ) return ResponseEntity.ok(SignUpResponse(user)) } @@ -165,6 +167,7 @@ data class SignUpRequest( val nickname: String, val phoneNumber: String, val email: String, + val role: UserRole = UserRole.USER, ) data class SignUpResponse(val user: User) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt index eef18d9..f6c15e2 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt @@ -17,7 +17,7 @@ class UserIdentityEntity( @JoinColumn(name = "user_id") var user: UserEntity, @Column(name = "role", nullable = false) - var role: String, + var role: UserRole = UserRole.USER, @Column(name = "hashed_password", nullable = false) val hashedPassword: String, @Column(name = "provider", nullable = false) @@ -25,3 +25,7 @@ class UserIdentityEntity( @Column(name = "social_id", nullable = true) val socialId: String? = null, ) + +enum class UserRole { + USER, ADMIN +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityRepository.kt index 057b511..628636f 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityRepository.kt @@ -4,4 +4,5 @@ import org.springframework.data.jpa.repository.JpaRepository interface UserIdentityRepository : JpaRepository { fun findByUser(user: UserEntity): UserIdentityEntity? + fun findByUserId(userId: String): UserIdentityEntity? } diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt index f2fb16a..fd774c5 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt @@ -6,6 +6,7 @@ import com.wafflestudio.interpark.user.persistence.UserEntity import com.wafflestudio.interpark.user.persistence.UserIdentityEntity import com.wafflestudio.interpark.user.persistence.UserIdentityRepository import com.wafflestudio.interpark.user.persistence.UserRepository +import com.wafflestudio.interpark.user.persistence.UserRole import org.mindrot.jbcrypt.BCrypt import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service @@ -24,6 +25,7 @@ class UserService( nickname: String, phoneNumber: String, email: String, + role: UserRole = UserRole.USER, ): User { if (username.length < 6 || username.length > 20) { throw SignUpBadUsernameException() @@ -47,7 +49,7 @@ class UserService( userIdentityRepository.save( UserIdentityEntity( user = user, - role = "USER", + role = role, hashedPassword = encryptedPassword, provider = "self", ), @@ -86,4 +88,8 @@ class UserService( fun refreshAccessToken(refreshToken: String): Pair { return userAccessTokenUtil.refreshAccessToken(refreshToken) ?: throw AuthenticateException() } + + fun getUserIdentityByUserId(userId: String): UserIdentityEntity? { + return userIdentityRepository.findByUserId(userId) + } } diff --git a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt index e419349..468efd2 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt @@ -3,6 +3,7 @@ package com.wafflestudio.interpark import com.fasterxml.jackson.databind.ObjectMapper import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import com.wafflestudio.interpark.performance.persistence.PerformanceRepository +import com.wafflestudio.interpark.user.persistence.UserRole import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -26,15 +27,18 @@ constructor( private val mvc: MockMvc, private val mapper: ObjectMapper, ) { - private lateinit var accessToken: String + private lateinit var userAccessToken: String + private lateinit var adminAccessToken: String private lateinit var performanceId: String @BeforeEach fun setUp() { val username = UUID.randomUUID().toString().take(8) + val adminname = UUID.randomUUID().toString().takeLast(8) val password = "password123" // 1️⃣ 회원가입 + // 일반 유저 mvc.perform( post("/api/v1/signup") .content( @@ -51,8 +55,27 @@ constructor( .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().`is`(200)) + // 관리자 + mvc.perform( + post("/api/v1/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to adminname, + "password" to password, + "nickname" to "test_admin", + "phoneNumber" to "010-0000-0000", + "email" to "test@example.com", + "role" to UserRole.ADMIN, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + // 2️⃣ 로그인 → 토큰 획득 - accessToken = + // 일반 유저 + userAccessToken = mvc.perform( post("/api/v1/signin") .content( @@ -70,11 +93,30 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it).get("accessToken").asText() } + // 관리자 + adminAccessToken = + mvc.perform( + post("/api/v1/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to adminname, + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + // 3️⃣ 테스트용 공연 ID 반환 performanceId = mvc.perform( get("/api/v1/performance/search") - .header("Authorization", "Bearer $accessToken") + .header("Authorization", "Bearer $userAccessToken") .param("title", "지킬앤하이드") .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().`is`(200)) @@ -95,7 +137,7 @@ constructor( // 4️⃣ 공연 검색 (title 조건) mvc.perform( get("/api/v1/performance/search") - .header("Authorization", "Bearer $accessToken") + .header("Authorization", "Bearer $userAccessToken") .param("title", "지킬앤하이드") .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().`is`(200)) @@ -105,7 +147,7 @@ constructor( // 5️⃣ 공연 검색 (category 조건) mvc.perform( get("/api/v1/performance/search") - .header("Authorization", "Bearer $accessToken") + .header("Authorization", "Bearer $userAccessToken") .param("category", PerformanceCategory.CONCERT.name) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().`is`(200)) @@ -119,7 +161,7 @@ constructor( println("Generated performanceId: $performanceId") mvc.perform( get("/api/v1/performance/$performanceId") - .header("Authorization", "Bearer $accessToken"), + .header("Authorization", "Bearer $userAccessToken"), ).andExpect(status().`is`(200)) .andExpect(jsonPath("$.id").value(performanceId)) .andExpect(jsonPath("$.title").value("뮤지컬 지킬앤하이드")) @@ -127,22 +169,28 @@ constructor( @Test fun `공연 삭제 플로우 테스트`() { + // 일반 유저 공연 삭제 실패 + mvc.perform( + delete("/admin/v1/performance/$performanceId") + .header("Authorization", "Bearer $userAccessToken"), + ).andExpect(status().`is`(403)) + // 7️⃣ 공연 삭제 mvc.perform( delete("/admin/v1/performance/$performanceId") - .header("Authorization", "Bearer $accessToken"), + .header("Authorization", "Bearer $adminAccessToken"), ).andExpect(status().`is`(204)) // 8️⃣ 삭제된 공연 상세 조회 실패 확인 mvc.perform( get("/api/v1/performance/$performanceId") - .header("Authorization", "Bearer $accessToken"), + .header("Authorization", "Bearer $adminAccessToken"), ).andExpect(status().`is`(404)) .andExpect(jsonPath("$.error").value("Performance Not Found")) } @Test - fun `공연 생성 테스트`() { + fun `공연 생성 테스트 - 관리자 성공`() { // 1️⃣ 공연 생성 요청 데이터 val createPerformanceRequest = mapOf( "title" to "뮤지컬 캣츠", @@ -155,7 +203,7 @@ constructor( // 2️⃣ 공연 생성 요청 및 응답 확인 val result = mvc.perform( post("/admin/v1/performance") - .header("Authorization", "Bearer $accessToken") + .header("Authorization", "Bearer $adminAccessToken") .content(mapper.writeValueAsString(createPerformanceRequest)) .contentType(MediaType.APPLICATION_JSON), ) @@ -168,11 +216,32 @@ constructor( .andReturn() } + @Test + fun `공연 생성 테스트 - 일반 유저 실패`() { + // 1️⃣ 공연 생성 요청 데이터 + val createPerformanceRequest = mapOf( + "title" to "뮤지컬 캣츠", + "detail" to "https://example.com/cats-detail.jpg", + "category" to PerformanceCategory.MUSICAL.name, + "posterUri" to "https://example.com/cats-poster.jpg", + "backdropImageUri" to "https://example.com/cats-backdrop.jpg" + ) + + // 2️⃣ 공연 생성 요청 및 응답 확인 + val result = mvc.perform( + post("/admin/v1/performance") + .header("Authorization", "Bearer $userAccessToken") + .content(mapper.writeValueAsString(createPerformanceRequest)) + .contentType(MediaType.APPLICATION_JSON), + ) + .andExpect(status().`is`(403)) // HTTP 403 Forbidden 확인 + } + @Test fun `공연 생성 실패 - 필수 정보 누락`() { mvc.perform( post("/admin/v1/performance") - .header("Authorization", "Bearer $accessToken") + .header("Authorization", "Bearer $adminAccessToken") .content( mapper.writeValueAsString( mapOf( From a2faaa92f84e36844a79e60f6335ce11893144f9 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Thu, 16 Jan 2025 19:26:17 +0900 Subject: [PATCH 040/162] check auth for create, delete performanceEvent, performanceHall --- .../controller/PerformanceEventController.kt | 27 ++++++++++++++++--- .../controller/PerformanceHallController.kt | 27 ++++++++++++++++--- .../interpark/PerformanceIntegrationTest.kt | 3 --- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt index 99870bc..7cf8e2a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt @@ -3,6 +3,8 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.service.PerformanceEventService import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.AuthUser +import com.wafflestudio.interpark.user.persistence.UserRole +import com.wafflestudio.interpark.user.service.UserService import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @@ -10,6 +12,7 @@ import org.springframework.web.bind.annotation.* @RestController class PerformanceEventController( private val performanceEventService: PerformanceEventService, + private val userService: UserService ) { @GetMapping("/api/v1/performance-event") fun getPerformanceEvent( @@ -17,7 +20,7 @@ class PerformanceEventController( ): ResponseEntity { // Currently, no search val performanceEventList: List = performanceEventService - .getAllPerformanceEvent(); + .getAllPerformanceEvent() return ResponseEntity.ok(performanceEventList) } @@ -26,7 +29,16 @@ class PerformanceEventController( @PostMapping("/admin/v1/performance-event") fun createPerformanceEvent( @RequestBody request: CreatePerformanceEventRequest, + @AuthUser user: User, ): ResponseEntity { + // UserIdentity를 통해 역할(Role) 확인 + val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 + ?: return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) // UserIdentity가 없으면 FORBIDDEN 반환 + + if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) + } + val newPerformanceEvent: PerformanceEvent = performanceEventService .createPerformanceEvent( @@ -34,14 +46,23 @@ class PerformanceEventController( request.performanceHallId, request.startAt, request.endAt - ); + ) return ResponseEntity.status(HttpStatus.CREATED).body(newPerformanceEvent) } @DeleteMapping("/admin/v1/performance-event/{performanceEventId}") fun deletePerformanceEvent( - @PathVariable performanceEventId: String + @PathVariable performanceEventId: String, + @AuthUser user: User ): ResponseEntity { + // UserIdentity를 통해 역할(Role) 확인 + val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 + ?: return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) // UserIdentity가 없으면 FORBIDDEN 반환 + + if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) + } + performanceEventService.deletePerformanceEvent(performanceEventId) return ResponseEntity.noContent().build() } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt index b2dd2c3..5642372 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt @@ -3,6 +3,8 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.service.PerformanceHallService import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.AuthUser +import com.wafflestudio.interpark.user.persistence.UserRole +import com.wafflestudio.interpark.user.service.UserService import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @@ -10,6 +12,7 @@ import org.springframework.web.bind.annotation.* @RestController class PerformanceHallController( private val performanceHallService: PerformanceHallService, + private val userService: UserService ) { @GetMapping("/api/v1/performance-hall") fun getPerformanceHall( @@ -17,7 +20,7 @@ class PerformanceHallController( ): ResponseEntity { // Currently, no search val performanceHallList: List = performanceHallService - .getAllPerformanceHall(); + .getAllPerformanceHall() return ResponseEntity.ok(performanceHallList) } @@ -26,21 +29,39 @@ class PerformanceHallController( @PostMapping("/admin/v1/performance-hall") fun createPerformanceHall( @RequestBody request: CreatePerformanceHallRequest, + @AuthUser user: User ): ResponseEntity { + // UserIdentity를 통해 역할(Role) 확인 + val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 + ?: return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) // UserIdentity가 없으면 FORBIDDEN 반환 + + if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) + } + val newPerformanceHall: PerformanceHall = performanceHallService .createPerformanceHall( request.name, request.address, request.maxAudience, - ); + ) return ResponseEntity.status(HttpStatus.CREATED).body(newPerformanceHall) } @DeleteMapping("/admin/v1/performance-hall/{performanceHallId}") fun deletePerformance( - @PathVariable performanceHallId: String + @PathVariable performanceHallId: String, + @AuthUser user: User ): ResponseEntity { + // UserIdentity를 통해 역할(Role) 확인 + val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 + ?: return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) // UserIdentity가 없으면 FORBIDDEN 반환 + + if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) + } + performanceHallService.deletePerformanceHall(performanceHallId) return ResponseEntity.noContent().build() } diff --git a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt index 468efd2..ef8de17 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt @@ -2,15 +2,12 @@ package com.wafflestudio.interpark import com.fasterxml.jackson.databind.ObjectMapper import com.wafflestudio.interpark.performance.persistence.PerformanceCategory -import com.wafflestudio.interpark.performance.persistence.PerformanceRepository import com.wafflestudio.interpark.user.persistence.UserRole -import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* From b83a853cc7810ac2fb50bec8acfd50c507173f85 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Thu, 16 Jan 2025 21:32:17 +0900 Subject: [PATCH 041/162] add posterUri attr to BriefPerformanceDetail --- .../com/wafflestudio/interpark/config/DataInitializer.kt | 1 - .../interpark/performance/controller/Performance.kt | 3 ++- .../interpark/performance/controller/PerformanceController.kt | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index ca12d59..cf74dd0 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -20,7 +20,6 @@ class DataInitializer( private val performanceHallRepository: PerformanceHallRepository, ) : CommandLineRunner { override fun run(vararg args: String?) { - // 1) 공연장 데이터 넣기 performanceHallService.createPerformanceHall( name = "블루스퀘어 신한카드홀", diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt index 79b1781..445be02 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt @@ -56,7 +56,8 @@ data class Performance( Pair(minDate, maxDate) } else { null - } + }, + posterUri = performanceEntity.posterUri, ) } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index 9376bc3..e4e576c 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -98,6 +98,7 @@ data class BriefPerformanceDetail( val title: String, val hallName: String, val performanceDuration: Pair?, + val posterUri: String, // 추후 제공 예정 // val ratingAvg: Double, // val reviewCount: Int, From d0a22eb3aa560441ca212e2c0ade191606e1fe1f Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Fri, 17 Jan 2025 00:58:17 +0900 Subject: [PATCH 042/162] minor change --- build.gradle.kts | 1 + .../com/wafflestudio/interpark/{ => config}/WebConfig.kt | 2 +- .../interpark/performance/PerformanceHallException.kt | 6 ++++++ .../performance/persistence/PerformanceHallRepository.kt | 2 ++ .../interpark/performance/service/PerformanceHallService.kt | 4 ++++ 5 files changed, 14 insertions(+), 1 deletion(-) rename src/main/kotlin/com/wafflestudio/interpark/{ => config}/WebConfig.kt (92%) diff --git a/build.gradle.kts b/build.gradle.kts index 2ac73e0..231e961 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-validation") + //implementation("org.springframework.boot:spring-boot-starter-security") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.mindrot:jbcrypt:0.4") diff --git a/src/main/kotlin/com/wafflestudio/interpark/WebConfig.kt b/src/main/kotlin/com/wafflestudio/interpark/config/WebConfig.kt similarity index 92% rename from src/main/kotlin/com/wafflestudio/interpark/WebConfig.kt rename to src/main/kotlin/com/wafflestudio/interpark/config/WebConfig.kt index e48756d..a549bc2 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/WebConfig.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/WebConfig.kt @@ -1,4 +1,4 @@ -package com.wafflestudio.interpark +package com.wafflestudio.interpark.config import com.wafflestudio.interpark.user.UserArgumentResolver import org.springframework.context.annotation.Configuration diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceHallException.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceHallException.kt index fc96b96..a6b79ef 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceHallException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceHallException.kt @@ -15,4 +15,10 @@ class PerformanceHallNotFoundException : PerformanceHallException( errorCode = 0, httpStatusCode = HttpStatus.NOT_FOUND, msg = "PerformanceHall Not Found", +) + +class PerformanceHallNameConflictException : PerformanceHallException( + errorCode = 0, + httpStatusCode = HttpStatus.CONFLICT, + msg = "PerformanceHallName Conflict", ) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallRepository.kt index cf98f23..8ab6262 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallRepository.kt @@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository interface PerformanceHallRepository : JpaRepository { fun findByName(name: String): PerformanceHallEntity? + + fun existsByName(name: String): Boolean } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceHallService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceHallService.kt index 29ff809..7e95d25 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceHallService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceHallService.kt @@ -22,6 +22,10 @@ class PerformanceHallService( address: String, maxAudience: Int, ): PerformanceHall { + if (performanceHallRepository.existsByName(name)) { + throw PerformanceHallNameConflictException() + } + val newPerformanceHallEntity: PerformanceHallEntity = PerformanceHallEntity( id = "", name = name, From 96ac9a50c0b49839810cb599a16194cafb09296e Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Fri, 17 Jan 2025 02:24:24 +0900 Subject: [PATCH 043/162] =?UTF-8?q?feat:=20=EC=98=88=EB=A7=A4=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20Brief=EB=A1=9C=20=EC=A3=BC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존에 id만 주던 것을 Brief 데이터의 리스트를 주는 것으로 수정 signin을 할 때 User도 같이 반환하도록 수정 --- .../interpark/seat/controller/Reservation.kt | 17 +++++++++++++++++ .../interpark/seat/controller/SeatController.kt | 15 ++++++++++++--- .../seat/persistence/ReservationRepository.kt | 1 + .../interpark/seat/service/SeatService.kt | 14 ++++++++++++-- .../interpark/user/controller/UserController.kt | 11 ++++++++--- .../interpark/user/service/UserService.kt | 5 +++-- .../interpark/SeatIntegrationTest.kt | 8 ++++---- 7 files changed, 57 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt index a6d2e81..bc80b44 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt @@ -7,10 +7,12 @@ import com.wafflestudio.interpark.seat.persistence.ReservationEntity import com.wafflestudio.interpark.seat.persistence.SeatEntity import java.time.Instant import java.time.LocalDate +import java.time.ZoneId data class Reservation( val id: String, val performanceTitle: String, + val posterUri: String, val performanceHallName: String, val seat: Seat, val performanceStartAt: Instant, @@ -28,6 +30,7 @@ data class Reservation( return Reservation( id = reservationEntity.id!!, performanceTitle = performanceEntity.title, + posterUri = performanceEntity.posterUri, performanceHallName = performanceHallEntity.name, seat = Seat.fromEntity(seatEntity), performanceStartAt = performanceEventEntity.startAt, @@ -35,5 +38,19 @@ data class Reservation( reservationDate = reservationEntity.reservationDate!!, ) } + + fun fromEntityToBriefDetails( + reservationEntity: ReservationEntity, + performanceEntity: PerformanceEntity, + performanceEventEntity: PerformanceEventEntity, + ): BriefReservation { + return BriefReservation( + id = reservationEntity.id!!, + performanceTitle = performanceEntity.title, + posterUri = performanceEntity.posterUri, + performanceDate = performanceEventEntity.startAt.atZone(ZoneId.of("Asia/Seoul")).toLocalDate(), + reservationDate = reservationEntity.reservationDate!!, + ) + } } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt index 13818c2..e667b30 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt @@ -9,6 +9,8 @@ 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.RestController +import java.time.Instant +import java.time.LocalDate @RestController class SeatController( @@ -35,8 +37,8 @@ class SeatController( fun getMyReservations( @AuthUser user: User, ): ResponseEntity { - val myReservationIds = seatService.getMyReservations(user) - return ResponseEntity.ok(GetMyReservationsResponse(myReservationIds)) + val myReservations = seatService.getMyReservations(user) + return ResponseEntity.ok(GetMyReservationsResponse(myReservations)) } @GetMapping("/api/v1/reservation/detail/{reservationId}") @@ -58,6 +60,13 @@ class SeatController( } } +data class BriefReservation( + val id: String, + val performanceTitle: String, + val posterUri: String, + val performanceDate: LocalDate, + val reservationDate: LocalDate, +) data class AvailableSeat( val reservationId: String, val seat: Seat, @@ -76,7 +85,7 @@ data class ReserveSeatResponse( ) data class GetMyReservationsResponse( - val myReservationIds: List, + val myReservations: List, ) data class GetReservedSeatDetailResponse( diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt index 47130a2..826d118 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Query interface ReservationRepository : JpaRepository { + @Query("SELECT r FROM ReservationEntity r WHERE r.user.id = :userId ORDER BY r.reservationDate DESC") fun findByUserId(userId: String): List fun findByPerformanceEventIdAndReservedIsFalse(performanceEventId: String): List diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt index b08adab..ca7c83f 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt @@ -4,6 +4,7 @@ import com.wafflestudio.interpark.seat.ReservationNotFoundException import com.wafflestudio.interpark.seat.ReservationPermissionDeniedException import com.wafflestudio.interpark.seat.ReservedAlreadyException import com.wafflestudio.interpark.seat.ReservedYetException +import com.wafflestudio.interpark.seat.controller.BriefReservation import com.wafflestudio.interpark.seat.controller.Reservation import com.wafflestudio.interpark.seat.controller.Seat import com.wafflestudio.interpark.seat.persistence.ReservationRepository @@ -48,11 +49,20 @@ class SeatService( } @Transactional - fun getMyReservations(user: User): List { + fun getMyReservations(user: User): List { userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() val myReservations = reservationRepository.findByUserId(user.id) - return myReservations.map { it.id!! } + return myReservations.map { reservationEntity -> + val performanceEventEntity = reservationEntity.performanceEvent + val performanceEntity = performanceEventEntity.performance + + Reservation.fromEntityToBriefDetails( + reservationEntity = reservationEntity, + performanceEntity = performanceEntity, + performanceEventEntity = performanceEventEntity + ) + } } @Transactional diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt index 9c8fc33..a293566 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt @@ -101,8 +101,8 @@ class UserController( fun signin( @RequestBody request: SignInRequest, response: HttpServletResponse, - ): ResponseEntity { - val (accessToken, refreshToken) = userService.signIn(request.username, request.password) + ): ResponseEntity { + val (user, accessToken, refreshToken) = userService.signIn(request.username, request.password) val cookie = Cookie("refreshToken", refreshToken).apply { isHttpOnly = true @@ -113,7 +113,7 @@ class UserController( } response.addCookie(cookie) - return ResponseEntity.ok(TokenResponse(accessToken)) + return ResponseEntity.ok(SignInResponse(user, accessToken)) } @GetMapping("/api/v1/users/me") @@ -174,6 +174,11 @@ data class SignInRequest( val password: String, ) +data class SignInResponse( + val user: User, + val accessToken: String, +) + data class TokenResponse( val accessToken: String, ) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt index f2fb16a..579427c 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt @@ -59,15 +59,16 @@ class UserService( fun signIn( username: String, password: String, - ): Pair { + ): Triple { val targetUser = userRepository.findByUsername(username) ?: throw SignInUserNotFoundException() + val user = User.fromEntity(targetUser) val targetIdentity = userIdentityRepository.findByUser(targetUser) ?: throw SignInUserNotFoundException() if (!BCrypt.checkpw(password, targetIdentity.hashedPassword)) { throw SignInInvalidPasswordException() } val accessToken = userAccessTokenUtil.generateAccessToken(targetUser.id!!) val refreshToken = userAccessTokenUtil.generateRefreshToken(targetIdentity.id!!) - return Pair(accessToken, refreshToken) + return Triple(user, accessToken, refreshToken) } @Transactional diff --git a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt index 7daa693..35dcb14 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt @@ -198,7 +198,7 @@ constructor( .contentType(MediaType.APPLICATION_JSON) ).andExpect(status().`is`(200)) - val myReservationIds = mvc.perform( + val myReservations = mvc.perform( get("/api/v1/me/reservation") .header("Authorization", "Bearer $accessToken") ).andExpect(status().`is`(200)) @@ -206,10 +206,10 @@ constructor( .response .getContentAsString(Charsets.UTF_8) .let { - mapper.readTree(it).get("myReservationIds") + mapper.readTree(it).get("myReservations") } - assert(myReservationIds.size() == 1) {"Expected size 1 but ${myReservationIds.size()}"} - assert(myReservationIds[0].asText() == reservationId) {"Expected $reservationId but ${myReservationIds[0].asText()}"} + assert(myReservations.size() == 1) {"Expected size 1 but ${myReservations.size()}"} + assert(myReservations[0].get("id").asText() == reservationId) {"Expected $reservationId but ${myReservations[0].get("id").asText()}"} } @Test From ea16879fbb666ca4cbf3b40812dccb63bc8666c6 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Fri, 17 Jan 2025 04:42:29 +0900 Subject: [PATCH 044/162] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83,=20=ED=86=A0=ED=81=B0=20=EC=9E=AC=EB=B0=9C=EA=B8=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 버그 수정, 엔드포인트 수정 --- .../interpark/user/UserException.kt | 2 +- .../user/controller/UserController.kt | 22 +++-- .../interpark/user/service/UserService.kt | 2 +- .../interpark/ReplyIntegrationTest.kt | 8 +- .../interpark/ReviewIntegrationTest.kt | 8 +- .../interpark/SeatIntegrationTest.kt | 8 +- .../interpark/SimultaneousTest.kt | 4 +- .../interpark/UserIntegrationTest.kt | 81 ++++++++++++++++--- src/test/resources/UserApi.http | 6 +- 9 files changed, 98 insertions(+), 43 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt b/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt index f0dd1ab..247297a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt @@ -53,7 +53,7 @@ class TokenExpiredException : UserException( msg = "Token Expired", ) -class TokenNotFoundException : UserException( +class NoRefreshTokenException : UserException( errorCode = 0, httpStatusCode = HttpStatus.UNAUTHORIZED, msg = "Token not found", diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt index a293566..08bd473 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt @@ -24,7 +24,7 @@ class UserController( return ResponseEntity.ok(mapOf("message" to "pong")) } - @PostMapping("/api/v1/signup") + @PostMapping("/api/v1/local/signup") @Operation( summary = "사용자 회원가입", description = """ @@ -97,7 +97,7 @@ class UserController( return ResponseEntity.ok(SignUpResponse(user)) } - @PostMapping("/api/v1/signin") + @PostMapping("/api/v1/local/signin") fun signin( @RequestBody request: SignInRequest, response: HttpServletResponse, @@ -107,7 +107,7 @@ class UserController( Cookie("refreshToken", refreshToken).apply { isHttpOnly = true secure = true - path = "/api/v1/refresh_token" + path = "/api/v1/auth" maxAge = 60 * 60 * 24 * 7 // TODO("domain 설정하기") } @@ -123,24 +123,24 @@ class UserController( return ResponseEntity.ok(user) } - @PostMapping("/api/v1/signout") + @PostMapping("/api/v1/auth/signout") fun signout( - @CookieValue(value = "refresh_token", required = false) refreshToken: String?, + @CookieValue(value = "refreshToken", required = false) refreshToken: String?, ): ResponseEntity { if (refreshToken == null) { - throw TokenNotFoundException() + throw NoRefreshTokenException() } userService.signOut(refreshToken) return ResponseEntity.noContent().build() } - @PostMapping("/api/v1/refresh_token") + @PostMapping("/api/v1/auth/refresh_token") fun refreshToken( @CookieValue(value = "refreshToken", required = false) refreshToken: String?, response: HttpServletResponse, ): ResponseEntity { if (refreshToken == null) { - throw TokenNotFoundException() + throw NoRefreshTokenException() } val (newAccessToken, newRefreshToken) = userService.refreshAccessToken(refreshToken) @@ -149,7 +149,7 @@ class UserController( Cookie("refreshToken", newRefreshToken).apply { isHttpOnly = true secure = true - path = "/api/v1/refresh_token" + path = "/api/v1/auth" maxAge = 60 * 60 * 24 * 7 // TODO("domain 설정하기") } @@ -182,7 +182,3 @@ data class SignInResponse( data class TokenResponse( val accessToken: String, ) - -data class SignOutRequest(val refreshToken: String) - -data class RefreshTokenRequest(val refreshToken: String) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt index 579427c..3dc7122 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt @@ -67,7 +67,7 @@ class UserService( throw SignInInvalidPasswordException() } val accessToken = userAccessTokenUtil.generateAccessToken(targetUser.id!!) - val refreshToken = userAccessTokenUtil.generateRefreshToken(targetIdentity.id!!) + val refreshToken = userAccessTokenUtil.generateRefreshToken(targetUser.id!!) return Triple(user, accessToken, refreshToken) } diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt index 476c4da..7cabd0e 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt @@ -34,7 +34,7 @@ class ReplyControllerTest // 1️⃣ 회원가입 mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -52,7 +52,7 @@ class ReplyControllerTest // 2️⃣ 로그인 → 토큰 획득 accessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( @@ -199,7 +199,7 @@ class ReplyControllerTest // 다른 사용자 생성 mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -217,7 +217,7 @@ class ReplyControllerTest // 다른 사용자 로그인 val otherAccessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt index 7c86a32..391de23 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt @@ -34,7 +34,7 @@ class ReviewIntegrationTest // 1️⃣ 회원가입 mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -52,7 +52,7 @@ class ReviewIntegrationTest // 2️⃣ 로그인 → 토큰 획득 accessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( @@ -178,7 +178,7 @@ class ReviewIntegrationTest fun `리뷰 삭제 실패 - 권한 없음`() { // 다른 사용자 생성 mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -196,7 +196,7 @@ class ReviewIntegrationTest // 다른 사용자로 로그인 val otherAccessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( diff --git a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt index 35dcb14..99244d9 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt @@ -36,7 +36,7 @@ constructor( // 1️⃣ 회원가입 mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -54,7 +54,7 @@ constructor( // 2️⃣ 로그인 → 토큰 획득 accessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( @@ -310,7 +310,7 @@ constructor( } mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -327,7 +327,7 @@ constructor( .andExpect(status().`is`(200)) val otherAccessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( diff --git a/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt index 1ca16d7..1ac4c17 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt @@ -38,7 +38,7 @@ constructor( // 1️⃣ 회원가입 mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -56,7 +56,7 @@ constructor( // 2️⃣ 로그인 → 토큰 획득 val accessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( diff --git a/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt index d1ded75..defc230 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt @@ -1,10 +1,15 @@ package com.wafflestudio.interpark import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.interpark.user.UserAccessTokenUtil +import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.persistence.UserRepository +import jakarta.servlet.http.Cookie import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest +import org.springframework.data.repository.findByIdOrNull import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get @@ -21,12 +26,14 @@ class UserIntegrationTest constructor( private val mvc: MockMvc, private val mapper: ObjectMapper, + private val userAccessTokenUtil: UserAccessTokenUtil, + private val userRepository: UserRepository, ) { @Test fun `회원가입시에 유저 이름 혹은 비밀번호가 정해진 규칙에 맞지 않는 경우 400 응답을 내려준다`() { // bad username mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -44,7 +51,7 @@ class UserIntegrationTest // bad password mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -61,7 +68,7 @@ class UserIntegrationTest .andExpect(status().`is`(400)) mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -81,7 +88,7 @@ class UserIntegrationTest @Test fun `회원가입시에 이미 해당 유저 이름이 존재하면 409 응답을 내려준다`() { mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -98,7 +105,7 @@ class UserIntegrationTest .andExpect(status().`is`(200)) mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -118,7 +125,7 @@ class UserIntegrationTest @Test fun `로그인 정보가 정확하지 않으면 401 응답을 내려준다`() { mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -136,7 +143,7 @@ class UserIntegrationTest // not exist username mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( @@ -151,7 +158,7 @@ class UserIntegrationTest // wrong password mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( @@ -165,7 +172,7 @@ class UserIntegrationTest .andExpect(status().`is`(401)) mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( @@ -179,11 +186,63 @@ class UserIntegrationTest .andExpect(status().`is`(200)) } + @Test + fun `토큰 재발행이 가능하다`() { + val (username, password) = "correct5" to "12345678" + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + "nickname" to username, + "phoneNumber" to "010-0000-0000", + "email" to "test@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ) + .andExpect(status().`is`(200)) + + val refreshToken = mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andReturn().response.cookies.find { it.name == "refreshToken" }!!.value + + val newAccessToken = + mvc.perform( + post("/api/v1/auth/refresh_token") + .cookie(Cookie("refreshToken", refreshToken)) + ) + .andExpect(status().`is`(200)) + .andReturn() + .response.getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + mvc.perform( + get("/api/v1/users/me") + .header("Authorization", "Bearer $newAccessToken"), + ) + .andExpect(status().`is`(200)) + .andExpect(jsonPath("$.username").value(username)) + .andExpect(jsonPath("$.nickname").value(username)) + } + @Test fun `잘못된 인증 토큰으로 인증시 401 응답을 내려준다`() { val (username, password) = "correct4" to "12345678" mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -201,7 +260,7 @@ class UserIntegrationTest val accessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( diff --git a/src/test/resources/UserApi.http b/src/test/resources/UserApi.http index e12f293..6811bdf 100644 --- a/src/test/resources/UserApi.http +++ b/src/test/resources/UserApi.http @@ -1,5 +1,5 @@ ### 회원가입 -POST http://localhost:8080/api/v1/signup +POST http://localhost:8080/api/v1/local/signup Content-Type: application/json { @@ -11,7 +11,7 @@ Content-Type: application/json } ### 로그인 -POST http://localhost:8080/api/v1/signin +POST http://localhost:8080/api/v1/local/signin Content-Type: application/json { @@ -20,7 +20,7 @@ Content-Type: application/json } ### 로그아웃 -POST http://localhost:8080/api/v1/signout +POST http://localhost:8080/api/v1/auth/signout Content-Type: application/json ### pingpong From 79da9d8e8d160d48cb2710d49c30c722c3fc9888 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Fri, 17 Jan 2025 10:25:16 +0900 Subject: [PATCH 045/162] add UserIdentityNotFoundException --- .../performance/controller/PerformanceController.kt | 5 +++-- .../performance/controller/PerformanceEventController.kt | 5 +++-- .../performance/controller/PerformanceHallController.kt | 5 +++-- .../kotlin/com/wafflestudio/interpark/user/UserException.kt | 6 ++++++ 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index e4e576c..037622d 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -4,6 +4,7 @@ import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import io.swagger.v3.oas.annotations.Operation import com.wafflestudio.interpark.performance.service.PerformanceService import com.wafflestudio.interpark.user.AuthUser +import com.wafflestudio.interpark.user.UserIdentityNotFoundException import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.persistence.UserRole import com.wafflestudio.interpark.user.service.UserService @@ -42,7 +43,7 @@ class PerformanceController( ): ResponseEntity { // UserIdentity를 통해 역할(Role) 확인 val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 - ?: return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) // UserIdentity가 없으면 FORBIDDEN 반환 + ?: throw UserIdentityNotFoundException() if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) @@ -79,7 +80,7 @@ class PerformanceController( ): ResponseEntity { // UserIdentity를 통해 역할(Role) 확인 val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 - ?: return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) // UserIdentity가 없으면 FORBIDDEN 반환 + ?: throw UserIdentityNotFoundException() if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt index 7cf8e2a..87e59d4 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt @@ -3,6 +3,7 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.service.PerformanceEventService import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.AuthUser +import com.wafflestudio.interpark.user.UserIdentityNotFoundException import com.wafflestudio.interpark.user.persistence.UserRole import com.wafflestudio.interpark.user.service.UserService import org.springframework.http.HttpStatus @@ -33,7 +34,7 @@ class PerformanceEventController( ): ResponseEntity { // UserIdentity를 통해 역할(Role) 확인 val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 - ?: return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) // UserIdentity가 없으면 FORBIDDEN 반환 + ?: throw UserIdentityNotFoundException() if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) @@ -57,7 +58,7 @@ class PerformanceEventController( ): ResponseEntity { // UserIdentity를 통해 역할(Role) 확인 val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 - ?: return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) // UserIdentity가 없으면 FORBIDDEN 반환 + ?: throw UserIdentityNotFoundException() if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt index 5642372..4b0ca94 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt @@ -3,6 +3,7 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.service.PerformanceHallService import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.AuthUser +import com.wafflestudio.interpark.user.UserIdentityNotFoundException import com.wafflestudio.interpark.user.persistence.UserRole import com.wafflestudio.interpark.user.service.UserService import org.springframework.http.HttpStatus @@ -33,7 +34,7 @@ class PerformanceHallController( ): ResponseEntity { // UserIdentity를 통해 역할(Role) 확인 val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 - ?: return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) // UserIdentity가 없으면 FORBIDDEN 반환 + ?: throw UserIdentityNotFoundException() if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) @@ -56,7 +57,7 @@ class PerformanceHallController( ): ResponseEntity { // UserIdentity를 통해 역할(Role) 확인 val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 - ?: return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) // UserIdentity가 없으면 FORBIDDEN 반환 + ?: throw UserIdentityNotFoundException() if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt b/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt index f0dd1ab..f9ab9b1 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt @@ -41,6 +41,12 @@ class SignInInvalidPasswordException : UserException( msg = "Invalid Password", ) +class UserIdentityNotFoundException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.NOT_FOUND, + msg = "UserIdentity not found", +) + class AuthenticateException : UserException( errorCode = 0, httpStatusCode = HttpStatus.UNAUTHORIZED, From be2e5bcc374bdf62450e10b5bdb836b4a8c21b55 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sat, 18 Jan 2025 14:10:06 +0900 Subject: [PATCH 046/162] modify PerformanceDurtion --- .../performance/controller/Performance.kt | 41 ++++++++++++++----- .../controller/PerformanceController.kt | 2 +- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt index 445be02..1f4051e 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt @@ -10,6 +10,7 @@ data class Performance( val title: String, val hallName: String, val performanceDates: List, + val performanceDuration: PerformanceDuration, val detail: String, val category: PerformanceCategory, val posterUri: String, @@ -31,6 +32,7 @@ data class Performance( performanceDates = performanceEvents?.map { it.startAt.atZone(ZoneId.of("Asia/Seoul")).toLocalDate() }?.distinct() ?: emptyList(), + performanceDuration = PerformanceDuration.fromPerformanceEvents(performanceEvents), detail = performanceEntity.detail, category = performanceEntity.category, posterUri = performanceEntity.posterUri, @@ -47,18 +49,37 @@ data class Performance( id = performanceEntity.id!!, title = performanceEntity.title, hallName = performanceHall?.name ?: "", - performanceDuration = if (!performanceEvents.isNullOrEmpty()) { - val seoulZone = ZoneId.of("Asia/Seoul") - - val minDate = performanceEvents.minOf { it.startAt }.atZone(seoulZone).toLocalDate() - val maxDate = performanceEvents.maxOf { it.startAt }.atZone(seoulZone).toLocalDate() - - Pair(minDate, maxDate) - } else { - null - }, + performanceDuration = PerformanceDuration.fromPerformanceEvents(performanceEvents), posterUri = performanceEntity.posterUri, ) } } } + +sealed class PerformanceDuration { + data object None : PerformanceDuration() // null 대체 + data class Single(val date: LocalDate) : PerformanceDuration() + data class Range(val start: LocalDate, val end: LocalDate) : PerformanceDuration() + + companion object { + fun fromPerformanceEvents(events: List?): PerformanceDuration { + if (events.isNullOrEmpty()) { + return None + } + + return when (events.size) { + 1 -> { + val seoulZone = ZoneId.of("Asia/Seoul") + val singleDate = events.first().startAt.atZone(seoulZone).toLocalDate() + Single(singleDate) + } + else -> { + val seoulZone = ZoneId.of("Asia/Seoul") + val minDate = events.minOf { it.startAt }.atZone(seoulZone).toLocalDate() + val maxDate = events.maxOf { it.startAt }.atZone(seoulZone).toLocalDate() + Range(minDate, maxDate) + } + } + } + } +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index 037622d..8ff1a10 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -98,7 +98,7 @@ data class BriefPerformanceDetail( val id: String, val title: String, val hallName: String, - val performanceDuration: Pair?, + val performanceDuration: PerformanceDuration, val posterUri: String, // 추후 제공 예정 // val ratingAvg: Double, From 3c72299b3325ca4fb89f571d9de86a515303c377 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sat, 18 Jan 2025 15:31:58 +0900 Subject: [PATCH 047/162] backup package to --- .../controller/PerformanceController.kt | 2 +- archive/user_old/AuthUser.kt | 5 + archive/user_old/UserAccessTokenUtil.kt | 86 ++++++++ archive/user_old/UserArgumentResolver.kt | 42 ++++ archive/user_old/UserException.kt | 66 +++++++ archive/user_old/controller/User.kt | 23 +++ archive/user_old/controller/UserController.kt | 186 ++++++++++++++++++ .../persistence/RefreshTokenEntity.kt | 21 ++ .../persistence/RefreshTokenRepository.kt | 9 + archive/user_old/persistence/UserEntity.kt | 24 +++ .../persistence/UserIdentityEntity.kt | 31 +++ .../persistence/UserIdentityRepository.kt | 8 + .../user_old/persistence/UserRepository.kt | 9 + archive/user_old/service/UserService.kt | 95 +++++++++ 14 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 archive/user_old/AuthUser.kt create mode 100644 archive/user_old/UserAccessTokenUtil.kt create mode 100644 archive/user_old/UserArgumentResolver.kt create mode 100644 archive/user_old/UserException.kt create mode 100644 archive/user_old/controller/User.kt create mode 100644 archive/user_old/controller/UserController.kt create mode 100644 archive/user_old/persistence/RefreshTokenEntity.kt create mode 100644 archive/user_old/persistence/RefreshTokenRepository.kt create mode 100644 archive/user_old/persistence/UserEntity.kt create mode 100644 archive/user_old/persistence/UserIdentityEntity.kt create mode 100644 archive/user_old/persistence/UserIdentityRepository.kt create mode 100644 archive/user_old/persistence/UserRepository.kt create mode 100644 archive/user_old/service/UserService.kt diff --git a/archive/performance_old/controller/PerformanceController.kt b/archive/performance_old/controller/PerformanceController.kt index 41bd506..b4eacae 100644 --- a/archive/performance_old/controller/PerformanceController.kt +++ b/archive/performance_old/controller/PerformanceController.kt @@ -10,7 +10,7 @@ import org.springframework.web.bind.annotation.RestController class PerformanceController( private val performanceService: PerformanceService, ) { - @GetMapping("v1/performance/search") + @GetMapping("api/v1/performance/search") fun searchPerformance( @RequestParam title: String?, @RequestParam genre: String?, diff --git a/archive/user_old/AuthUser.kt b/archive/user_old/AuthUser.kt new file mode 100644 index 0000000..58bd34a --- /dev/null +++ b/archive/user_old/AuthUser.kt @@ -0,0 +1,5 @@ +package com.wafflestudio.interpark.user + +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class AuthUser diff --git a/archive/user_old/UserAccessTokenUtil.kt b/archive/user_old/UserAccessTokenUtil.kt new file mode 100644 index 0000000..7804127 --- /dev/null +++ b/archive/user_old/UserAccessTokenUtil.kt @@ -0,0 +1,86 @@ +package com.wafflestudio.interpark.user + +import com.wafflestudio.interpark.user.persistence.RefreshTokenEntity +import com.wafflestudio.interpark.user.persistence.RefreshTokenRepository +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.springframework.stereotype.Component +import java.nio.charset.StandardCharsets +import java.util.* + +@Component +class UserAccessTokenUtil( + private var refreshTokenRepository: RefreshTokenRepository, +) { + fun generateAccessToken(username: String): String { + val now = Date() + val expiryDate = Date(now.time + ACCESS_EXPIRATION_TIME) + return Jwts.builder() + .signWith(SECRET_KEY) + .setSubject(username) + .setIssuedAt(now) + .setExpiration(expiryDate) + .compact() + } + + fun validateAccessToken(accessToken: String): String? { + return try { + val claims = + Jwts.parserBuilder() + .setSigningKey(SECRET_KEY) + .build() + .parseClaimsJws(accessToken) + .body + if (claims.expiration < Date()) { + throw TokenExpiredException() + } + return claims.subject + } catch (e: Exception) { + null + } + } + + fun generateRefreshToken(userId: String): String { + val now = Date() + val expiryDate = Date(now.time + REFRESH_EXPIRATION_TIME) + val refreshToken = UUID.randomUUID().toString() + + // 해당 유저의 다른 refreshToken 이 있다면 삭제 + val existingToken = refreshTokenRepository.findByUserId(userId) + if (existingToken != null) { + refreshTokenRepository.delete(existingToken) + } + + refreshTokenRepository.save( + RefreshTokenEntity( + userId = userId, + refreshToken = refreshToken, + expiryDate = expiryDate, + ), + ) + return refreshToken + } + + fun refreshAccessToken(refreshToken: String): Pair? { + val storedRefreshToken = refreshTokenRepository.findByRefreshToken(refreshToken) ?: return null + + if (storedRefreshToken.expiryDate < Date()) throw TokenExpiredException() + + val newAccessToken = generateAccessToken(storedRefreshToken.userId) + val newRefreshToken = generateRefreshToken(storedRefreshToken.userId) + + return Pair(newAccessToken, newRefreshToken) + } + + fun removeRefreshToken(refreshToken: String) { + val storedRefreshToken = refreshTokenRepository.findByRefreshToken(refreshToken) ?: return + refreshTokenRepository.delete(storedRefreshToken) + } + + companion object { + private const val ACCESS_EXPIRATION_TIME = 1000 * 60 * 15 // 15 minutes + private const val REFRESH_EXPIRATION_TIME = 1000 * 60 * 60 * 24 // 1 day + private val SECRET_KEY = Keys.hmacShaKeyFor("THISSHOULDBEPROTECTEDASDFASDFASDFASDFASDFASDF".toByteArray(StandardCharsets.UTF_8)) + // TODO("비밀키 숨겨야 한다") + } +} diff --git a/archive/user_old/UserArgumentResolver.kt b/archive/user_old/UserArgumentResolver.kt new file mode 100644 index 0000000..7d81f35 --- /dev/null +++ b/archive/user_old/UserArgumentResolver.kt @@ -0,0 +1,42 @@ +package com.wafflestudio.interpark.user + +import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.service.UserService +import org.springframework.core.MethodParameter +import org.springframework.stereotype.Component +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +@Component +class UserArgumentResolver( + private val userService: UserService, +) : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean { + return parameter.parameterType == User::class.java + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): User? { + return runCatching { + val accessToken = + requireNotNull( + webRequest.getHeader("Authorization")?.split(" ")?.let { + if (it.getOrNull(0) == "Bearer") it.getOrNull(1) else null + }, + ) + userService.authenticate(accessToken) + }.getOrElse { + if (parameter.hasParameterAnnotation(AuthUser::class.java)) { + throw AuthenticateException() + } else { + null + } + } + } +} diff --git a/archive/user_old/UserException.kt b/archive/user_old/UserException.kt new file mode 100644 index 0000000..f9ab9b1 --- /dev/null +++ b/archive/user_old/UserException.kt @@ -0,0 +1,66 @@ +package com.wafflestudio.interpark.user + +import com.wafflestudio.interpark.DomainException +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatusCode + +sealed class UserException( + errorCode: Int, + httpStatusCode: HttpStatusCode, + msg: String, + cause: Throwable? = null, +) : DomainException(errorCode, httpStatusCode, msg, cause) + +class SignUpBadUsernameException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "Bad Username", +) + +class SignUpBadPasswordException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "Bad Password", +) + +class SignUpUsernameConflictException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.CONFLICT, + msg = "Username Conflict", +) + +class SignInUserNotFoundException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.UNAUTHORIZED, + msg = "User not found", +) + +class SignInInvalidPasswordException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.UNAUTHORIZED, + msg = "Invalid Password", +) + +class UserIdentityNotFoundException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.NOT_FOUND, + msg = "UserIdentity not found", +) + +class AuthenticateException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.UNAUTHORIZED, + msg = "Unauthorized", +) + +class TokenExpiredException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.UNAUTHORIZED, + msg = "Token Expired", +) + +class TokenNotFoundException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.UNAUTHORIZED, + msg = "Token not found", +) diff --git a/archive/user_old/controller/User.kt b/archive/user_old/controller/User.kt new file mode 100644 index 0000000..cb9f1e1 --- /dev/null +++ b/archive/user_old/controller/User.kt @@ -0,0 +1,23 @@ +package com.wafflestudio.interpark.user.controller + +import com.wafflestudio.interpark.user.persistence.UserEntity + +data class User( + val id: String, + val username: String, + val nickname: String, + val phoneNumber: String, + val email: String, +) { + companion object { + fun fromEntity(entity: UserEntity): User { + return User( + id = entity.id!!, + username = entity.username, + nickname = entity.nickname, + phoneNumber = entity.phoneNumber, + email = entity.email, + ) + } + } +} diff --git a/archive/user_old/controller/UserController.kt b/archive/user_old/controller/UserController.kt new file mode 100644 index 0000000..a388b1d --- /dev/null +++ b/archive/user_old/controller/UserController.kt @@ -0,0 +1,186 @@ +package com.wafflestudio.interpark.user.controller + +import com.wafflestudio.interpark.user.* +import com.wafflestudio.interpark.user.persistence.UserRole +import com.wafflestudio.interpark.user.service.UserService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.servlet.http.Cookie +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +class UserController( + private val userService: UserService, +) { + @GetMapping("/api/v1/ping") + @Operation( + summary = "핑퐁 테스트", + description = "\"ping\"을 보내면 \"pong\"을 반환합니다." + ) + fun ping() : ResponseEntity> { + return ResponseEntity.ok(mapOf("message" to "pong")) + } + + @PostMapping("/api/v1/signup") + @Operation( + summary = "사용자 회원가입", + description = """ + 새로운 사용자를 등록합니다. + 사용자 이름, 비밀번호, 닉네임, 이메일, 전화번호를 입력받아 저장합니다. + 요청이 유효하지 않은 경우 또는 사용자 이름이 중복된 경우 적절한 에러 메시지를 반환합니다. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "회원가입 성공", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = SignUpResponse::class) + )] + ), + ApiResponse( + responseCode = "400", + description = "잘못된 요청 (사용자 이름 또는 비밀번호가 유효하지 않음)", + content = [Content( + mediaType = "application/json", + schema = Schema( + type = "object", + example = """ + { + "error": "비밀번호는 8~12자여야 합니다.", + "errorCode": "INVALID_PASSWORD" + } + """ + ) + )] + ), + ApiResponse( + responseCode = "409", + description = "중복된 사용자 이름 (사용자 이름이 이미 존재함)", + content = [Content( + mediaType = "application/json", + schema = Schema( + type = "object", + example = """ + { + "error": "사용자 이름이 이미 존재합니다.", + "errorCode": "USERNAME_CONFLICT" + } + """ + ) + )] + ) + ], + requestBody = io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "회원가입 요청 데이터", + required = true, + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = SignUpRequest::class) + )] + ) + ) + fun signup( + @RequestBody request: SignUpRequest, + ): ResponseEntity { + val user = + userService.signUp( + username = request.username, + password = request.password, + nickname = request.nickname, + email = request.email, + phoneNumber = request.phoneNumber, + role = request.role, + ) + return ResponseEntity.ok(SignUpResponse(user)) + } + + @PostMapping("/api/v1/signin") + fun signin( + @RequestBody request: SignInRequest, + response: HttpServletResponse, + ): ResponseEntity { + val (accessToken, refreshToken) = userService.signIn(request.username, request.password) + val cookie = + Cookie("refreshToken", refreshToken).apply { + isHttpOnly = true + secure = true + path = "/api/v1/refresh_token" + maxAge = 60 * 60 * 24 * 7 + // TODO("domain 설정하기") + } + response.addCookie(cookie) + + return ResponseEntity.ok(TokenResponse(accessToken)) + } + + @GetMapping("/api/v1/users/me") + fun me( + @AuthUser user: User, + ): ResponseEntity { + return ResponseEntity.ok(user) + } + + @PostMapping("/api/v1/signout") + fun signout( + @CookieValue(value = "refresh_token", required = false) refreshToken: String?, + ): ResponseEntity { + if (refreshToken == null) { + throw TokenNotFoundException() + } + userService.signOut(refreshToken) + return ResponseEntity.noContent().build() + } + + @PostMapping("/api/v1/refresh_token") + fun refreshToken( + @CookieValue(value = "refreshToken", required = false) refreshToken: String?, + response: HttpServletResponse, + ): ResponseEntity { + if (refreshToken == null) { + throw TokenNotFoundException() + } + + val (newAccessToken, newRefreshToken) = userService.refreshAccessToken(refreshToken) + + val cookie = + Cookie("refreshToken", newRefreshToken).apply { + isHttpOnly = true + secure = true + path = "/api/v1/refresh_token" + maxAge = 60 * 60 * 24 * 7 + // TODO("domain 설정하기") + } + response.addCookie(cookie) + + return ResponseEntity.ok(TokenResponse(newAccessToken)) + } +} + +data class SignUpRequest( + val username: String, + val password: String, + val nickname: String, + val phoneNumber: String, + val email: String, + val role: UserRole = UserRole.USER, +) + +data class SignUpResponse(val user: User) + +data class SignInRequest( + val username: String, + val password: String, +) + +data class TokenResponse( + val accessToken: String, +) + +data class SignOutRequest(val refreshToken: String) + +data class RefreshTokenRequest(val refreshToken: String) diff --git a/archive/user_old/persistence/RefreshTokenEntity.kt b/archive/user_old/persistence/RefreshTokenEntity.kt new file mode 100644 index 0000000..ab253d1 --- /dev/null +++ b/archive/user_old/persistence/RefreshTokenEntity.kt @@ -0,0 +1,21 @@ +package com.wafflestudio.interpark.user.persistence + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import java.util.* + +@Entity +class RefreshTokenEntity( + @Id + @GeneratedValue(strategy = GenerationType.UUID) + val id: String? = null, + @Column(name = "user_id", nullable = false) + val userId: String, + @Column(name = "refresh_token", nullable = false, unique = true) + val refreshToken: String, + @Column(name = "expiry_date", nullable = false) + val expiryDate: Date, +) diff --git a/archive/user_old/persistence/RefreshTokenRepository.kt b/archive/user_old/persistence/RefreshTokenRepository.kt new file mode 100644 index 0000000..88e3ca8 --- /dev/null +++ b/archive/user_old/persistence/RefreshTokenRepository.kt @@ -0,0 +1,9 @@ +package com.wafflestudio.interpark.user.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface RefreshTokenRepository : JpaRepository { + fun findByRefreshToken(refreshToken: String): RefreshTokenEntity? + + fun findByUserId(userId: String): RefreshTokenEntity? +} diff --git a/archive/user_old/persistence/UserEntity.kt b/archive/user_old/persistence/UserEntity.kt new file mode 100644 index 0000000..9ddccb0 --- /dev/null +++ b/archive/user_old/persistence/UserEntity.kt @@ -0,0 +1,24 @@ +package com.wafflestudio.interpark.user.persistence + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id + +@Entity +class UserEntity( + @Id + @GeneratedValue(strategy = GenerationType.UUID) + val id: String? = null, + @Column(name = "username", nullable = false) + val username: String, + @Column(name = "nickname", nullable = false) + val nickname: String, + @Column(name = "phone_number", nullable = false) + val phoneNumber: String, + @Column(name = "email", nullable = false) + val email: String, + @Column(name = "address", nullable = true) + val address: String? = null, +) diff --git a/archive/user_old/persistence/UserIdentityEntity.kt b/archive/user_old/persistence/UserIdentityEntity.kt new file mode 100644 index 0000000..f6c15e2 --- /dev/null +++ b/archive/user_old/persistence/UserIdentityEntity.kt @@ -0,0 +1,31 @@ +package com.wafflestudio.interpark.user.persistence + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.OneToOne + +@Entity +class UserIdentityEntity( + @Id + @GeneratedValue(strategy = GenerationType.UUID) + val id: String? = null, + @OneToOne + @JoinColumn(name = "user_id") + var user: UserEntity, + @Column(name = "role", nullable = false) + var role: UserRole = UserRole.USER, + @Column(name = "hashed_password", nullable = false) + val hashedPassword: String, + @Column(name = "provider", nullable = false) + val provider: String, + @Column(name = "social_id", nullable = true) + val socialId: String? = null, +) + +enum class UserRole { + USER, ADMIN +} \ No newline at end of file diff --git a/archive/user_old/persistence/UserIdentityRepository.kt b/archive/user_old/persistence/UserIdentityRepository.kt new file mode 100644 index 0000000..628636f --- /dev/null +++ b/archive/user_old/persistence/UserIdentityRepository.kt @@ -0,0 +1,8 @@ +package com.wafflestudio.interpark.user.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface UserIdentityRepository : JpaRepository { + fun findByUser(user: UserEntity): UserIdentityEntity? + fun findByUserId(userId: String): UserIdentityEntity? +} diff --git a/archive/user_old/persistence/UserRepository.kt b/archive/user_old/persistence/UserRepository.kt new file mode 100644 index 0000000..b010531 --- /dev/null +++ b/archive/user_old/persistence/UserRepository.kt @@ -0,0 +1,9 @@ +package com.wafflestudio.interpark.user.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface UserRepository : JpaRepository { + fun findByUsername(username: String): UserEntity? + + fun existsByUsername(username: String): Boolean +} diff --git a/archive/user_old/service/UserService.kt b/archive/user_old/service/UserService.kt new file mode 100644 index 0000000..fd774c5 --- /dev/null +++ b/archive/user_old/service/UserService.kt @@ -0,0 +1,95 @@ +package com.wafflestudio.interpark.user.service + +import com.wafflestudio.interpark.user.* +import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.persistence.UserEntity +import com.wafflestudio.interpark.user.persistence.UserIdentityEntity +import com.wafflestudio.interpark.user.persistence.UserIdentityRepository +import com.wafflestudio.interpark.user.persistence.UserRepository +import com.wafflestudio.interpark.user.persistence.UserRole +import org.mindrot.jbcrypt.BCrypt +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class UserService( + private val userRepository: UserRepository, + private val userIdentityRepository: UserIdentityRepository, + private val userAccessTokenUtil: UserAccessTokenUtil, +) { + @Transactional + fun signUp( + username: String, + password: String, + nickname: String, + phoneNumber: String, + email: String, + role: UserRole = UserRole.USER, + ): User { + if (username.length < 6 || username.length > 20) { + throw SignUpBadUsernameException() + } + if (password.length < 8 || password.length > 12) { + throw SignUpBadPasswordException() + } + if (userRepository.existsByUsername(username)) { + throw SignUpUsernameConflictException() + } + val encryptedPassword = BCrypt.hashpw(password, BCrypt.gensalt()) + val user = + userRepository.save( + UserEntity( + username = username, + nickname = nickname, + phoneNumber = phoneNumber, + email = email, + ), + ) + userIdentityRepository.save( + UserIdentityEntity( + user = user, + role = role, + hashedPassword = encryptedPassword, + provider = "self", + ), + ) + return User.fromEntity(user) + } + + @Transactional + fun signIn( + username: String, + password: String, + ): Pair { + val targetUser = userRepository.findByUsername(username) ?: throw SignInUserNotFoundException() + val targetIdentity = userIdentityRepository.findByUser(targetUser) ?: throw SignInUserNotFoundException() + if (!BCrypt.checkpw(password, targetIdentity.hashedPassword)) { + throw SignInInvalidPasswordException() + } + val accessToken = userAccessTokenUtil.generateAccessToken(targetUser.id!!) + val refreshToken = userAccessTokenUtil.generateRefreshToken(targetIdentity.id!!) + return Pair(accessToken, refreshToken) + } + + @Transactional + fun signOut(refreshToken: String) { + userAccessTokenUtil.removeRefreshToken(refreshToken) + } + + @Transactional + fun authenticate(accessToken: String): User { + val userId = userAccessTokenUtil.validateAccessToken(accessToken) ?: throw AuthenticateException() + val user = userRepository.findByIdOrNull(userId) ?: throw AuthenticateException() + return User.fromEntity(user) + } + + @Transactional + fun refreshAccessToken(refreshToken: String): Pair { + return userAccessTokenUtil.refreshAccessToken(refreshToken) ?: throw AuthenticateException() + } + + fun getUserIdentityByUserId(userId: String): UserIdentityEntity? { + return userIdentityRepository.findByUserId(userId) + } +} From c5f9ecd87e908c047f3e79651795e430c2b491eb Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sat, 18 Jan 2025 15:58:29 +0900 Subject: [PATCH 048/162] modify endpoint --- .../wafflestudio/interpark/PerformanceIntegrationTest.kt | 8 ++++---- .../com/wafflestudio/interpark/ReplyIntegrationTest.kt | 2 +- .../kotlin/com/wafflestudio/interpark/SimultaneousTest.kt | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt index ef8de17..76e6b3e 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt @@ -37,7 +37,7 @@ constructor( // 1️⃣ 회원가입 // 일반 유저 mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -54,7 +54,7 @@ constructor( // 관리자 mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -74,7 +74,7 @@ constructor( // 일반 유저 userAccessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( @@ -93,7 +93,7 @@ constructor( // 관리자 adminAccessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt index 22d02f8..8691045 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt @@ -199,7 +199,7 @@ class ReplyIntegrationTest // 다른 사용자 생성 mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( diff --git a/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt index 1ac4c17..9659985 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt @@ -87,7 +87,7 @@ constructor( } val performanceId = mvc.perform( - get("/v1/performance/search") + get("/api/v1/performance/search") ).andExpect(status().`is`(200)) .andReturn() .response From f814f70a04ae3400974aa2d0ed9c0234717ec9ec Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sat, 18 Jan 2025 19:00:47 +0900 Subject: [PATCH 049/162] =?UTF-8?q?feat:=20=EA=B3=B5=EC=97=B0=EC=9E=A5=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=8B=9C=20=EC=A2=8C=EC=84=9D=EB=8F=84=20?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interpark/performance/service/PerformanceHallService.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceHallService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceHallService.kt index 29ff809..9380f86 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceHallService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceHallService.kt @@ -3,6 +3,7 @@ package com.wafflestudio.interpark.performance.service import com.wafflestudio.interpark.performance.* import com.wafflestudio.interpark.performance.controller.* import com.wafflestudio.interpark.performance.persistence.* +import com.wafflestudio.interpark.seat.service.SeatCreationService import org.springframework.data.jpa.domain.Specification import org.springframework.stereotype.Service import org.springframework.data.repository.findByIdOrNull @@ -10,6 +11,7 @@ import org.springframework.data.repository.findByIdOrNull @Service class PerformanceHallService( private val performanceHallRepository: PerformanceHallRepository, + private val seatCreationService: SeatCreationService ) { fun getAllPerformanceHall(): List { return performanceHallRepository @@ -30,6 +32,7 @@ class PerformanceHallService( ).let{ performanceHallRepository.save(it) } + seatCreationService.createSeats(newPerformanceHallEntity.id!!, "DEFAULT") return PerformanceHall.fromEntity(newPerformanceHallEntity) } From f1839af220f2ab29bacb5a7bc564b44d16c43349 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sun, 19 Jan 2025 01:15:12 +0900 Subject: [PATCH 050/162] =?UTF-8?q?feat:=20seat=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EC=A0=81=EC=9A=A9,=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PerformanceHall이 생성될 때 Seat을 같이 생성, PerformanceEvent가 생성될 때 Reservation을 같이 생성하도록 변경 --- .../service/PerformanceEventService.kt | 7 +++ .../seat/controller/SeatController.kt | 1 - .../interpark/PerformanceIntegrationTest.kt | 8 +-- .../interpark/ReplyIntegrationTest.kt | 4 +- .../interpark/SeatIntegrationTest.kt | 53 +++---------------- .../interpark/SimultaneousTest.kt | 43 --------------- 6 files changed, 20 insertions(+), 96 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt index 4416584..4ebec8f 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt @@ -3,6 +3,9 @@ package com.wafflestudio.interpark.performance.service import com.wafflestudio.interpark.performance.* import com.wafflestudio.interpark.performance.controller.* import com.wafflestudio.interpark.performance.persistence.* +import com.wafflestudio.interpark.seat.persistence.ReservationRepository +import com.wafflestudio.interpark.seat.persistence.SeatRepository +import com.wafflestudio.interpark.seat.service.SeatCreationService import org.springframework.data.jpa.domain.Specification import org.springframework.stereotype.Service import org.springframework.data.repository.findByIdOrNull @@ -16,6 +19,7 @@ class PerformanceEventService( private val performanceRepository: PerformanceRepository, private val performanceHallRepository: PerformanceHallRepository, private val performanceEventRepository: PerformanceEventRepository, + private val seatCreationService: SeatCreationService ) { fun getAllPerformanceEvent(): List { return performanceEventRepository @@ -47,6 +51,9 @@ class PerformanceEventService( ).let{ performanceEventRepository.save(it) } + + seatCreationService.createEmptyReservations(newPerformanceEventEntity.id) + return PerformanceEvent.fromEntity(newPerformanceEventEntity) } diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt index e667b30..7968bda 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt @@ -9,7 +9,6 @@ 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.RestController -import java.time.Instant import java.time.LocalDate @RestController diff --git a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt index ef8de17..76e6b3e 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt @@ -37,7 +37,7 @@ constructor( // 1️⃣ 회원가입 // 일반 유저 mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -54,7 +54,7 @@ constructor( // 관리자 mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -74,7 +74,7 @@ constructor( // 일반 유저 userAccessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( @@ -93,7 +93,7 @@ constructor( // 관리자 adminAccessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt index 6754d8f..8691045 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt @@ -199,7 +199,7 @@ class ReplyIntegrationTest // 다른 사용자 생성 mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -217,7 +217,7 @@ class ReplyIntegrationTest // 다른 사용자 로그인 val otherAccessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( diff --git a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt index 511a3dd..d4dfcb8 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt @@ -27,8 +27,6 @@ constructor( private val seatCreationService: SeatCreationService, ) { private lateinit var accessToken: String - private lateinit var performanceHallId: String - private lateinit var performanceId: String private lateinit var performanceEventId: String @BeforeEach fun setup() { @@ -71,46 +69,6 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it).get("accessToken").asText() } - //Seat와 Reservation 만들기 위한 EventId 만들기 - performanceHallId = - mvc.perform( - get("/api/v1/performance-hall") - .header("Authorization", "Bearer $accessToken"), - ).andExpect(status().`is`(200)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { - val performanceHalls = mapper.readTree(it) - performanceHalls[0].get("id").asText() - } - performanceId = - mvc.perform( - get("/api/v1/performance/search") - ).andExpect(status().`is`(200)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { - val performances = mapper.readTree(it) - performances[0].get("id").asText() - } - - mvc.perform( - post("/admin/v1/performance-event") - .content( - mapper.writeValueAsString( - mapOf( - "performanceId" to performanceId, - "performanceHallId" to performanceHallId, - "startAt" to LocalDateTime.now(ZoneId.of("Asia/Seoul")), - "endAt" to LocalDateTime.now(ZoneId.of("Asia/Seoul")) - ), - ), - ) - .contentType(MediaType.APPLICATION_JSON) - ) - performanceEventId = mvc.perform( get("/api/v1/performance-event") @@ -123,16 +81,19 @@ constructor( val performanceEvents = mapper.readTree(it) performanceEvents[0].get("id").asText() } - //Seat와 Reservation만들기 - seatCreationService.createSeats(performanceHallId, "DEFAULT") - seatCreationService.createEmptyReservations(performanceEventId) } @Test fun `가능한 좌석들의 정보를 받을 수 있다`() { - mvc.perform( + val availableSeats = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("availableSeats")} + + assert(availableSeats.size()==100) {"expected Seats are 100 but found ${availableSeats.size()}"} } @Test diff --git a/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt index 1ac4c17..989de33 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt @@ -72,46 +72,6 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it).get("accessToken").asText() } - //Seat와 Reservation 만들기 위한 EventId 만들기 - val performanceHallId = - mvc.perform( - get("/api/v1/performance-hall") - .header("Authorization", "Bearer $accessToken"), - ).andExpect(status().`is`(200)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { - val performanceHalls = mapper.readTree(it) - performanceHalls[0].get("id").asText() - } - val performanceId = - mvc.perform( - get("/v1/performance/search") - ).andExpect(status().`is`(200)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { - val performances = mapper.readTree(it) - performances[0].get("id").asText() - } - - mvc.perform( - post("/admin/v1/performance-event") - .content( - mapper.writeValueAsString( - mapOf( - "performanceId" to performanceId, - "performanceHallId" to performanceHallId, - "startAt" to Instant.now(), - "endAt" to Instant.now(), - ), - ), - ) - .contentType(MediaType.APPLICATION_JSON) - ) - val performanceEventId = mvc.perform( get("/api/v1/performance-event") @@ -124,9 +84,6 @@ constructor( val performanceEvents = mapper.readTree(it) performanceEvents[0].get("id").asText() } - //Seat와 Reservation만들기 - seatCreationService.createSeats(performanceHallId, "DEFAULT") - seatCreationService.createEmptyReservations(performanceEventId) val reservationId = mvc.perform( get("/api/v1/seat/$performanceEventId/available") From 433e6f4f245eef48b59278a8a47d6d9164d7984e Mon Sep 17 00:00:00 2001 From: DoHyeon Kim Date: Sun, 19 Jan 2025 17:16:33 +0900 Subject: [PATCH 051/162] =?UTF-8?q?=EA=B3=B5=EC=97=B0=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EA=B2=80=EC=83=89,=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C,=20=EC=83=9D=EC=84=B1,=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * merge test - generate performancehallentity.kt * feat: searchPerformance * modify searchPerformance * modify build.gradle.kts * add wildcard import in UserService.kt * change Performance attribute 'genre' to 'category' * change category to ENUM Type * add some Init Datas * add some Init Datas * assign posterimageURL * assign posterimageURL * modify searchPerformance, GET요청은 RequestBody를 사용할 수 없다고 하여 다시 RequestParam을 사용하는 원래 방식으로 변경 * modify MakeFile * 공연 검색 기능 구현 * fromEntity parameter change: receive Entities -> receive DTOs * modify searchPerformance endpoint URL * 공연 1개 상세 정보 반환 * minor modify: change how null type is handled * 초기 데이터 수정, 공연이벤트는 일단 공연 기간의 첫날과 마지막날만 생성하였음 * modify DataInitializer, SeatIntegrationTest * add PerformanceDetail Uri * make PerformanceItegrationTest.kt * modify SearchPerformanceResponse * check auth for create, delete performance * check auth for create, delete performanceEvent, performanceHall * add posterUri attr to BriefPerformanceDetail * minor change * add UserIdentityNotFoundException * modify PerformanceDurtion --- build.gradle.kts | 2 + .../interpark/GlobalExceptionHandler.kt | 14 + .../interpark/config/DataInitializer.kt | 446 ++++++++++-------- .../interpark/{ => config}/WebConfig.kt | 2 +- .../performance/PerformanceEventException.kt | 2 +- .../performance/PerformanceException.kt | 2 +- .../performance/PerformanceHallException.kt | 8 +- .../performance/controller/Performance.kt | 69 ++- .../controller/PerformanceController.kt | 75 ++- .../controller/PerformanceEventController.kt | 39 +- .../controller/PerformanceHallController.kt | 35 +- .../persistence/PerformanceEventRepository.kt | 2 +- .../persistence/PerformanceHallRepository.kt | 4 +- .../persistence/PerformanceRepository.kt | 2 +- .../service/PerformanceEventService.kt | 24 +- .../service/PerformanceHallService.kt | 4 + .../performance/service/PerformanceService.kt | 51 +- .../interpark/user/UserException.kt | 6 + .../user/controller/UserController.kt | 13 +- .../user/persistence/UserIdentityEntity.kt | 6 +- .../persistence/UserIdentityRepository.kt | 1 + .../interpark/user/service/UserService.kt | 8 +- .../interpark/PerformanceIntegrationTest.kt | 258 ++++++++++ .../interpark/ReplyIntegrationTest.kt | 2 +- .../interpark/SeatIntegrationTest.kt | 9 +- 25 files changed, 817 insertions(+), 267 deletions(-) rename src/main/kotlin/com/wafflestudio/interpark/{ => config}/WebConfig.kt (92%) create mode 100644 src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 88585e6..231e961 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,6 +20,8 @@ repositories { dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") + //implementation("org.springframework.boot:spring-boot-starter-security") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.mindrot:jbcrypt:0.4") diff --git a/src/main/kotlin/com/wafflestudio/interpark/GlobalExceptionHandler.kt b/src/main/kotlin/com/wafflestudio/interpark/GlobalExceptionHandler.kt index 88227d6..73195c1 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/GlobalExceptionHandler.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/GlobalExceptionHandler.kt @@ -1,6 +1,7 @@ package com.wafflestudio.interpark import org.springframework.http.ResponseEntity +import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ControllerAdvice import org.springframework.web.bind.annotation.ExceptionHandler @@ -12,4 +13,17 @@ class GlobalExceptionHandler { .status(exception.httpErrorCode) .body(mapOf("error" to exception.msg, "errorCode" to exception.errorCode)) } + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidationExceptions(exeption: MethodArgumentNotValidException): ResponseEntity> { + val errors = exeption.bindingResult.fieldErrors.associate { + it.field to (it.defaultMessage ?: "Invalid value") + } + return ResponseEntity.badRequest().body( + mapOf( + "error" to "Method Argument Validation failed", + "details" to errors + ) + ) + } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index 64b2183..cf74dd0 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -1,243 +1,279 @@ package com.wafflestudio.interpark.config +import com.wafflestudio.interpark.performance.PerformanceHallNotFoundException +import com.wafflestudio.interpark.performance.PerformanceNotFoundException import com.wafflestudio.interpark.performance.persistence.PerformanceCategory -import com.wafflestudio.interpark.performance.persistence.PerformanceEntity -import com.wafflestudio.interpark.performance.persistence.PerformanceHallEntity -import com.wafflestudio.interpark.performance.persistence.PerformanceRepository import com.wafflestudio.interpark.performance.persistence.PerformanceHallRepository +import com.wafflestudio.interpark.performance.persistence.PerformanceRepository +import com.wafflestudio.interpark.performance.service.PerformanceEventService +import com.wafflestudio.interpark.performance.service.PerformanceHallService +import com.wafflestudio.interpark.performance.service.PerformanceService import org.springframework.boot.CommandLineRunner import org.springframework.context.annotation.Configuration -import java.time.LocalDate @Configuration class DataInitializer( - private val performanceHallRepository: PerformanceHallRepository, + private val performanceService: PerformanceService, + private val performanceEventService: PerformanceEventService, + private val performanceHallService: PerformanceHallService, private val performanceRepository: PerformanceRepository, + private val performanceHallRepository: PerformanceHallRepository, ) : CommandLineRunner { override fun run(vararg args: String?) { - // 1) 공연장(Hall) 데이터 넣기 - val BlueSquare_ShinHanCardHall = performanceHallRepository.save( - PerformanceHallEntity( - name = "블루스퀘어 신한카드홀", - address = "서울 용산구 한남동 이태원로", - maxAudience = 100 - ) + // 1) 공연장 데이터 넣기 + performanceHallService.createPerformanceHall( + name = "블루스퀘어 신한카드홀", + address = "서울 용산구 한남동 이태원로", + maxAudience = 100 ) - val BlueSquare_MasterCardHall = performanceHallRepository.save( - PerformanceHallEntity( - name = "블루스퀘어 마스터카드홀", - address = "서울 용산구 한남동 이태원로", - maxAudience = 100 - ) + performanceHallService.createPerformanceHall( + name = "블루스퀘어 마스터카드홀", + address = "서울 용산구 한남동 이태원로", + maxAudience = 100 ) - val SeoulArtsCenter_OperaHouse = performanceHallRepository.save( - PerformanceHallEntity( - name = "예술의전당 오페라극장", - address = "서울 서초구 남부순환로", - maxAudience = 100 - ) + performanceHallService.createPerformanceHall( + name = "예술의전당 오페라극장", + address = "서울 서초구 남부순환로", + maxAudience = 100 ) - val SeoulArtsCenter_ConcertHall = performanceHallRepository.save( - PerformanceHallEntity( - name = "예술의전당 콘서트홀", - address = "서울 서초구 남부순환로", - maxAudience = 100 - ) + performanceHallService.createPerformanceHall( + name = "예술의전당 콘서트홀", + address = "서울 서초구 남부순환로", + maxAudience = 100 ) - val LGArtCenterSeoul_SIGNATUREHAll = performanceHallRepository.save( - PerformanceHallEntity( - name = "LG아트센터 서울 SIGNATURE홀", - address = "서울 강서구 마곡동 마곡중앙로", - maxAudience = 100 - ) + performanceHallService.createPerformanceHall( + name = "LG아트센터 서울 SIGNATURE홀", + address = "서울 강서구 마곡동 마곡중앙로", + maxAudience = 100 ) - val LGArtCenterSeoul_UPlusStage = performanceHallRepository.save( - PerformanceHallEntity( - name = "LG아트센터 서울 U+ 스테이지", - address = "서울 강서구 마곡동 마곡중앙로", - maxAudience = 100 - ) + performanceHallService.createPerformanceHall( + name = "LG아트센터 서울 U+ 스테이지", + address = "서울 강서구 마곡동 마곡중앙로", + maxAudience = 100 ) - val OlympicPark_OlympicHall = performanceHallRepository.save( - PerformanceHallEntity( - name = "올림픽공원 올림픽홀", - address = "서울 송파구 방이동", - maxAudience = 100 - ) + performanceHallService.createPerformanceHall( + name = "올림픽공원 올림픽홀", + address = "서울 송파구 방이동", + maxAudience = 100 ) - val GoyangSportsComplex_MainStadium = performanceHallRepository.save( - PerformanceHallEntity( - name = "고양종합운동장 주경기장", - address = "경기도 고양시 일산서구 대화동 중앙로", - maxAudience = 100 - ) + performanceHallService.createPerformanceHall( + name = "고양종합운동장 주경기장", + address = "경기도 고양시 일산서구 대화동 중앙로", + maxAudience = 100 ) - val SejongCenter_GrandTheater = performanceHallRepository.save( - PerformanceHallEntity( - name = "세종문화회관 대극장", - address = "서울 종로구 세종대로", - maxAudience = 100 - ) + performanceHallService.createPerformanceHall( + name = "세종문화회관 대극장", + address = "서울 종로구 세종대로", + maxAudience = 100 ) - val SejongCenter_MTheater = performanceHallRepository.save( - PerformanceHallEntity( - name = "세종문화회관 M씨어터", - address = "서울 종로구 세종대로", - maxAudience = 100 - ) + performanceHallService.createPerformanceHall( + name = "세종문화회관 M씨어터", + address = "서울 종로구 세종대로", + maxAudience = 100 ) - // 2) PerformanceEntity 여러개 생성 - val performanceList = listOf( - // 뮤지컬 - PerformanceEntity( - title = "뮤지컬 지킬앤하이드", - detail = "지금 이 순간, 끝나지 않는 신화", - category = PerformanceCategory.MUSICAL, - posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24013928_p.gif", - backdropImageUri = "http://example.com/backdrop/jekyll.jpg", - ), - PerformanceEntity( - //hall = LGArtCenterSeoul_SIGNATUREHAll, - title = "마타하리", - detail = "She's BACK!", - category = PerformanceCategory.MUSICAL, - //sales = 2000, - //dates = listOf(LocalDate.of(2024, 12, 5), LocalDate.of(2025, 3, 2)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/L0/L0000106_p.gif", - backdropImageUri = "http://example.com/backdrop/phantom.jpg", - //seatIds = listOf("B1", "B2", "B3"), - //reviewIds = listOf("review3", "review4") + // 2) Performance 데이터 넣기 + performanceService.createPerformance( + title = "뮤지컬 지킬앤하이드", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24013928-21.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24013928_p.gif", + backdropImageUri = "http://example.com/backdrop/jekyll.jpg" + ) + performanceService.createPerformance( + title = "마타하리", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/L0000106-08.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/L0/L0000106_p.gif", + backdropImageUri = "http://example.com/backdrop/phantom.jpg" + ) + performanceService.createPerformance( + title = "웃는남자", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24016737-04.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24016737_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "2025 기리보이 콘서트", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24018543-01.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24018543_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "2025 검정치마 단독공연", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000084-01.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000084_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "콜드플레이 내한공연", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24013437-06.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24013437_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "브루스 리우 피아노 리사이틀", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24016119-01.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24016119_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "크리스티안 테츨라프 바이올린 리사이틀", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24015137-01.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24015137_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "발레의 별빛, 글로벌 발레스타 초청 갈라공연", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/P0004046-06.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/P0/P0004046_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "연극 애나엑스", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/L0000107-02.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/L0/L0000107_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "연극 타인의 삶", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/L0000104-05.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/L0/L0000104_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "세일즈맨의 죽음", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24017573-06.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24017573_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + + // 3) Performance Event 데이터 넣기 + val performanceEvents = listOf( + Triple( + "뮤지컬 지킬앤하이드", + "블루스퀘어 신한카드홀", + listOf( + listOf("2024-11-29T16:00:00", "2024-11-29T18:00:00"), + listOf("2025-05-18T16:00:00", "2025-05-18T18:00:00") + ) ), - PerformanceEntity( - //hall = SeoulArtsCenter_OperaHouse, - title = "웃는남자", - detail = "부자들의 낙원은 가난한 자들의 지옥으로 세워진 것이다", - category = PerformanceCategory.MUSICAL, - //sales = 500, - //dates = listOf(LocalDate.of(2025, 1, 9), LocalDate.of(2025, 3, 9)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24016737_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = listOf("C1", "C2", "C3"), - //reviewIds = listOf("review5") + Triple( + "마타하리", + "LG아트센터 서울 SIGNATURE홀", + listOf( + listOf("2024-12-05T16:00:00", "2024-12-05T18:00:00"), + listOf("2025-03-02T16:00:00", "2025-03-02T18:00:00") + ) ), - - // 콘서트 - PerformanceEntity( - //hall = BlueSquare_MasterCardHall, - title = "2025 기리보이 콘서트", - detail = "2252:2522", - category = PerformanceCategory.CONCERT, - //sales = 0, - //dates = listOf(LocalDate.of(2025, 2, 1), LocalDate.of(2025, 2, 2)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24018543_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = emptyList(), - //reviewIds = emptyList() + Triple( + "웃는남자", + "예술의전당 오페라극장", + listOf( + listOf("2025-01-09T16:00:00", "2025-01-09T18:00:00"), + listOf("2025-03-09T16:00:00", "2025-03-09T18:00:00") + ) ), - PerformanceEntity( - //hall = OlympicPark_OlympicHall, - title = "2025 검정치마 단독공연", - detail = "SONGS TO BRING YOU HOME", - category = PerformanceCategory.CONCERT, - //sales = 0, - //dates = listOf(LocalDate.of(2025, 2, 1), LocalDate.of(2025, 2, 2)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000084_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = emptyList(), - //reviewIds = emptyList() + Triple( + "2025 기리보이 콘서트", + "블루스퀘어 마스터카드홀", + listOf( + listOf("2025-02-01T16:00:00", "2025-02-01T18:00:00"), + listOf("2025-02-02T16:00:00", "2025-02-02T18:00:00") + ) ), - PerformanceEntity( - //hall = GoyangSportsComplex_MainStadium, - title = "콜드플레이 내한공연", - detail = "MUSIC of the SPHERES", - category = PerformanceCategory.CONCERT, - //sales = 0, - //dates = listOf(LocalDate.of(2025, 4, 16), LocalDate.of(2025, 4, 25)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24013437_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = emptyList(), - //reviewIds = emptyList() + Triple( + "2025 검정치마 단독공연", + "올림픽공원 올림픽홀", + listOf( + listOf("2025-02-01T16:00:00", "2025-02-01T18:00:00"), + listOf("2025-02-02T16:00:00", "2025-02-02T18:00:00") + ) ), - - // 클래식 - PerformanceEntity( - //hall = SeoulArtsCenter_ConcertHall, - title = "브루스 리우 피아노 리사이틀", - detail = "TCHAIKOVSKY | MENDELSSOHN | SCRIABIN | PROKOFIEV", - category = PerformanceCategory.CLASSIC, - //sales = 0, - //dates = listOf(LocalDate.of(2025, 5, 11)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24016119_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = emptyList(), - //reviewIds = emptyList() + Triple( + "콜드플레이 내한공연", + "고양종합운동장 주경기장", + listOf( + listOf("2025-04-16T16:00:00", "2025-04-16T18:00:00"), + listOf("2025-04-25T16:00:00", "2025-04-25T18:00:00") + ) ), - PerformanceEntity( - //hall = SeoulArtsCenter_ConcertHall, - title = "크리스티안 테츨라프 바이올린 리사이틀", - detail = "SUK | BRAHMS | SZYMANOWSKI | FRANCK", - category = PerformanceCategory.CLASSIC, - //sales = 0, - //dates = listOf(LocalDate.of(2025, 5, 1)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24015137_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = emptyList(), - //reviewIds = emptyList() + Triple( + "브루스 리우 피아노 리사이틀", + "예술의전당 콘서트홀", + listOf( + listOf("2025-05-11T16:00:00", "2025-05-11T18:00:00") + ) ), - PerformanceEntity( - //hall = SejongCenter_GrandTheater, - title = "발레의 별빛, 글로벌 발레스타 초청 갈라공연", - detail = "전 세계가 먼저 찾는 한국 스타 무용수들의 향연!", - category = PerformanceCategory.CLASSIC, - //sales = 0, - //dates = listOf(LocalDate.of(2025, 1, 11), LocalDate.of(2025, 1, 12)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/P0/P0004046_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = emptyList(), - //reviewIds = emptyList() + Triple( + "크리스티안 테츨라프 바이올린 리사이틀", + "예술의전당 콘서트홀", + listOf( + listOf("2025-05-01T16:00:00", "2025-05-01T18:00:00") + ) ), - - // 연극 - PerformanceEntity( - //hall = LGArtCenterSeoul_UPlusStage, - title = "연극 애나엑스", - detail = "ANNA X", - category = PerformanceCategory.PLAY, - //sales = 0, - //dates = listOf(LocalDate.of(2025, 1, 28), LocalDate.of(2025, 3, 16)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/L0/L0000107_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = emptyList(), - //reviewIds = emptyList() + Triple( + "발레의 별빛, 글로벌 발레스타 초청 갈라공연", + "세종문화회관 대극장", + listOf( + listOf("2025-01-11T16:00:00", "2025-01-11T18:00:00"), + listOf("2025-01-12T16:00:00", "2025-01-12T18:00:00") + ) ), - PerformanceEntity( - //hall = LGArtCenterSeoul_UPlusStage, - title = "연극 타인의 삶", - detail = "영화 타인의 삶 원작", - category = PerformanceCategory.PLAY, - //sales = 0, - //dates = listOf(LocalDate.of(2025, 1, 28), LocalDate.of(2025, 3, 16)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/L0/L0000104_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = emptyList(), - //reviewIds = emptyList() + Triple( + "연극 애나엑스", + "LG아트센터 서울 U+ 스테이지", + listOf( + listOf("2025-01-28T16:00:00", "2025-01-28T18:00:00"), + listOf("2025-03-16T16:00:00", "2025-03-16T18:00:00") + ) ), - PerformanceEntity( - //hall = SejongCenter_MTheater, - title = "세일즈맨의 죽음", - detail = "현 희곡의 거장 '아서 밀러'의 대표작 연극<세일즈맨의 죽음>이 돌아왔다!", - category = PerformanceCategory.PLAY, - //sales = 0, - //dates = listOf(LocalDate.of(2025, 1, 7), LocalDate.of(2025, 3, 3)), - posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24017573_p.gif", - backdropImageUri = "http://example.com/backdrop/mom.jpg", - //seatIds = emptyList(), - //reviewIds = emptyList() + Triple( + "연극 타인의 삶", + "LG아트센터 서울 U+ 스테이지", + listOf( + listOf("2025-01-28T16:00:00", "2025-01-28T18:00:00"), + listOf("2025-03-16T16:00:00", "2025-03-16T18:00:00") + ) ), + Triple( + "세일즈맨의 죽음", + "세종문화회관 M씨어터", + listOf( + listOf("2025-01-07T16:00:00", "2025-01-07T18:00:00"), + listOf("2025-03-03T16:00:00", "2025-03-03T18:00:00") + ) + ) ) - // 3) 한번에 저장 - performanceRepository.saveAll(performanceList) + performanceEvents.forEach { (performanceTitle, hallName, eventTimes) -> + // PerformanceRepository를 통해 공연 조회 + val performance = performanceRepository.findByTitle(performanceTitle) + ?: throw PerformanceNotFoundException() + + // PerformanceHallRepository를 통해 공연장 조회 + val hall = performanceHallRepository.findByName(hallName) + ?: throw PerformanceHallNotFoundException() + + // 이벤트 생성 + eventTimes.forEach { (startAt, endAt) -> + performanceEventService.createPerformanceEvent( + performanceId = performance.id!!, + performanceHallId = hall.id!!, + startAt = startAt, + endAt = endAt + ) + } + } } } \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/WebConfig.kt b/src/main/kotlin/com/wafflestudio/interpark/config/WebConfig.kt similarity index 92% rename from src/main/kotlin/com/wafflestudio/interpark/WebConfig.kt rename to src/main/kotlin/com/wafflestudio/interpark/config/WebConfig.kt index e48756d..a549bc2 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/WebConfig.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/WebConfig.kt @@ -1,4 +1,4 @@ -package com.wafflestudio.interpark +package com.wafflestudio.interpark.config import com.wafflestudio.interpark.user.UserArgumentResolver import org.springframework.context.annotation.Configuration diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceEventException.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceEventException.kt index 546319d..415e6fb 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceEventException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceEventException.kt @@ -13,6 +13,6 @@ sealed class PerformanceEventException( class PerformanceEventNotFoundException : PerformanceEventException( errorCode = 0, - httpStatusCode = HttpStatus.BAD_REQUEST, + httpStatusCode = HttpStatus.NOT_FOUND, msg = "PerformanceEvent Not Found", ) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceException.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceException.kt index 9def87d..cc5d544 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceException.kt @@ -13,6 +13,6 @@ sealed class PerformanceException( class PerformanceNotFoundException : PerformanceException( errorCode = 0, - httpStatusCode = HttpStatus.BAD_REQUEST, + httpStatusCode = HttpStatus.NOT_FOUND, msg = "Performance Not Found", ) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceHallException.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceHallException.kt index 945bb9f..a6b79ef 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceHallException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/PerformanceHallException.kt @@ -13,6 +13,12 @@ sealed class PerformanceHallException( class PerformanceHallNotFoundException : PerformanceHallException( errorCode = 0, - httpStatusCode = HttpStatus.BAD_REQUEST, + httpStatusCode = HttpStatus.NOT_FOUND, msg = "PerformanceHall Not Found", +) + +class PerformanceHallNameConflictException : PerformanceHallException( + errorCode = 0, + httpStatusCode = HttpStatus.CONFLICT, + msg = "PerformanceHallName Conflict", ) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt index 62c6f06..1f4051e 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/Performance.kt @@ -3,10 +3,14 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import com.wafflestudio.interpark.performance.persistence.PerformanceEntity import java.time.LocalDate +import java.time.ZoneId data class Performance( val id: String, val title: String, + val hallName: String, + val performanceDates: List, + val performanceDuration: PerformanceDuration, val detail: String, val category: PerformanceCategory, val posterUri: String, @@ -16,15 +20,66 @@ data class Performance( // val performanceEventList: List, ) { companion object { - fun fromEntity(entity: PerformanceEntity): Performance { + fun fromEntity( + performanceEntity: PerformanceEntity, + performanceEvents: List?, + performanceHall: PerformanceHall?, + ): Performance { return Performance( - id = entity.id!!, - title = entity.title, - detail = entity.detail, - category = entity.category, - posterUri = entity.posterUri, - backdropImageUri = entity.backdropImageUri + id = performanceEntity.id!!, + title = performanceEntity.title, + hallName = performanceHall?.name ?: "", + performanceDates = performanceEvents?.map { + it.startAt.atZone(ZoneId.of("Asia/Seoul")).toLocalDate() + }?.distinct() ?: emptyList(), + performanceDuration = PerformanceDuration.fromPerformanceEvents(performanceEvents), + detail = performanceEntity.detail, + category = performanceEntity.category, + posterUri = performanceEntity.posterUri, + backdropImageUri = performanceEntity.backdropImageUri ) } + + fun fromEntityToBriefDetails( + performanceEntity: PerformanceEntity, + performanceEvents: List?, + performanceHall: PerformanceHall?, + ): BriefPerformanceDetail { + return BriefPerformanceDetail( + id = performanceEntity.id!!, + title = performanceEntity.title, + hallName = performanceHall?.name ?: "", + performanceDuration = PerformanceDuration.fromPerformanceEvents(performanceEvents), + posterUri = performanceEntity.posterUri, + ) + } + } +} + +sealed class PerformanceDuration { + data object None : PerformanceDuration() // null 대체 + data class Single(val date: LocalDate) : PerformanceDuration() + data class Range(val start: LocalDate, val end: LocalDate) : PerformanceDuration() + + companion object { + fun fromPerformanceEvents(events: List?): PerformanceDuration { + if (events.isNullOrEmpty()) { + return None + } + + return when (events.size) { + 1 -> { + val seoulZone = ZoneId.of("Asia/Seoul") + val singleDate = events.first().startAt.atZone(seoulZone).toLocalDate() + Single(singleDate) + } + else -> { + val seoulZone = ZoneId.of("Asia/Seoul") + val minDate = events.minOf { it.startAt }.atZone(seoulZone).toLocalDate() + val maxDate = events.maxOf { it.startAt }.atZone(seoulZone).toLocalDate() + Range(minDate, maxDate) + } + } + } } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index 34bc0d9..8ff1a10 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -3,16 +3,25 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import io.swagger.v3.oas.annotations.Operation import com.wafflestudio.interpark.performance.service.PerformanceService -import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.AuthUser +import com.wafflestudio.interpark.user.UserIdentityNotFoundException +import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.persistence.UserRole +import com.wafflestudio.interpark.user.service.UserService +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* +import java.time.LocalDate @RestController class PerformanceController( private val performanceService: PerformanceService, + private val userService: UserService, ) { - @GetMapping("v1/performance/search") + @GetMapping("/api/v1/performance/search") @Operation( summary = "공연 조회", description = "제목과 카테고리에 해당하는 공연들의 리스트를 반환합니다." @@ -24,13 +33,22 @@ class PerformanceController( val queriedPerformances = performanceService.searchPerformance(title, category) return ResponseEntity.ok(queriedPerformances) } - + // WARN: THIS IS FOR ADMIN. // TODO: SEPERATE THIS TO OTHER APPLICATION @PostMapping("/admin/v1/performance") fun createPerformance( - @RequestBody request: CreatePerformanceRequest, + @Valid @RequestBody request: CreatePerformanceRequest, + @AuthUser user: User, ): ResponseEntity { + // UserIdentity를 통해 역할(Role) 확인 + val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 + ?: throw UserIdentityNotFoundException() + + if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) + } + val newPerformance: Performance = performanceService .createPerformance( @@ -39,28 +57,67 @@ class PerformanceController( request.category, request.posterUri, request.backdropImageUri - ); - return ResponseEntity.ok(newPerformance) + ) + return ResponseEntity.status(HttpStatus.CREATED).body(newPerformance) + } + + @GetMapping("/api/v1/performance/{performanceId}") + @Operation( + summary = "공연 상세정보 반환", + description = "공연을 선택했을 때 화면에 보여지는 상세 정보를 반환합니다." + ) + fun getPerformanceDetail( + @PathVariable performanceId: String, + ) : ResponseEntity { + val queriedPerformance = performanceService.getPerformanceDetail(performanceId) + return ResponseEntity.ok(queriedPerformance) } @DeleteMapping("/admin/v1/performance/{performanceId}") fun deletePerformance( - @PathVariable performanceId: String + @PathVariable performanceId: String, + @AuthUser user: User, ): ResponseEntity { + // UserIdentity를 통해 역할(Role) 확인 + val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 + ?: throw UserIdentityNotFoundException() + + if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) + } + performanceService.deletePerformance(performanceId) return ResponseEntity.noContent().build() } } -typealias SearchPerformanceResponse = List +typealias SearchPerformanceResponse = List + +data class BriefPerformanceDetail( + val id: String, + val title: String, + val hallName: String, + val performanceDuration: PerformanceDuration, + val posterUri: String, + // 추후 제공 예정 + // val ratingAvg: Double, + // val reviewCount: Int, +) data class CreatePerformanceRequest( + @field:NotBlank(message = "Title must not be blank") val title: String, + @field:NotBlank(message = "Detail must not be blank") val detail: String, + @field:NotNull(message = "Category must not be null") val category: PerformanceCategory, + @field:NotBlank(message = "Poster URI must not be blank") val posterUri: String, - val backdropImageUri: String, + @field:NotBlank(message = "Backdrop Image URI must not be blank") + val backdropImageUri: String ) typealias CreatePerformanceResponse = Performance + +typealias GetPerformanceDetailResponse = Performance diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt index 931f065..87e59d4 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt @@ -3,14 +3,17 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.service.PerformanceEventService import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.AuthUser +import com.wafflestudio.interpark.user.UserIdentityNotFoundException +import com.wafflestudio.interpark.user.persistence.UserRole +import com.wafflestudio.interpark.user.service.UserService +import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* -import java.time.Instant - @RestController class PerformanceEventController( private val performanceEventService: PerformanceEventService, + private val userService: UserService ) { @GetMapping("/api/v1/performance-event") fun getPerformanceEvent( @@ -18,16 +21,25 @@ class PerformanceEventController( ): ResponseEntity { // Currently, no search val performanceEventList: List = performanceEventService - .getAllPerformanceEvent(); + .getAllPerformanceEvent() return ResponseEntity.ok(performanceEventList) } // WARN: THIS IS FOR ADMIN. // TODO: SEPERATE THIS TO OTHER APPLICATION @PostMapping("/admin/v1/performance-event") - fun createPerformance( + fun createPerformanceEvent( @RequestBody request: CreatePerformanceEventRequest, + @AuthUser user: User, ): ResponseEntity { + // UserIdentity를 통해 역할(Role) 확인 + val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 + ?: throw UserIdentityNotFoundException() + + if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) + } + val newPerformanceEvent: PerformanceEvent = performanceEventService .createPerformanceEvent( @@ -35,14 +47,23 @@ class PerformanceEventController( request.performanceHallId, request.startAt, request.endAt - ); - return ResponseEntity.ok(newPerformanceEvent) + ) + return ResponseEntity.status(HttpStatus.CREATED).body(newPerformanceEvent) } @DeleteMapping("/admin/v1/performance-event/{performanceEventId}") fun deletePerformanceEvent( - @PathVariable performanceEventId: String + @PathVariable performanceEventId: String, + @AuthUser user: User ): ResponseEntity { + // UserIdentity를 통해 역할(Role) 확인 + val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 + ?: throw UserIdentityNotFoundException() + + if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) + } + performanceEventService.deletePerformanceEvent(performanceEventId) return ResponseEntity.noContent().build() } @@ -54,8 +75,8 @@ typealias GetPerformanceEventResponse = List data class CreatePerformanceEventRequest( val performanceId: String, val performanceHallId: String, - val startAt: Instant, - val endAt: Instant, + val startAt: String, + val endAt: String, ) typealias CreatePerformanceEventResponse = PerformanceEvent \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt index d7b2913..4b0ca94 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt @@ -3,43 +3,66 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.service.PerformanceHallService import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.AuthUser +import com.wafflestudio.interpark.user.UserIdentityNotFoundException +import com.wafflestudio.interpark.user.persistence.UserRole +import com.wafflestudio.interpark.user.service.UserService +import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @RestController class PerformanceHallController( private val performanceHallService: PerformanceHallService, + private val userService: UserService ) { @GetMapping("/api/v1/performance-hall") - fun getPerformance( + fun getPerformanceHall( @AuthUser user: User, ): ResponseEntity { // Currently, no search val performanceHallList: List = performanceHallService - .getAllPerformanceHall(); + .getAllPerformanceHall() return ResponseEntity.ok(performanceHallList) } // WARN: THIS IS FOR ADMIN. // TODO: SEPERATE THIS TO OTHER APPLICATION @PostMapping("/admin/v1/performance-hall") - fun createPerformance( + fun createPerformanceHall( @RequestBody request: CreatePerformanceHallRequest, + @AuthUser user: User ): ResponseEntity { + // UserIdentity를 통해 역할(Role) 확인 + val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 + ?: throw UserIdentityNotFoundException() + + if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) + } + val newPerformanceHall: PerformanceHall = performanceHallService .createPerformanceHall( request.name, request.address, request.maxAudience, - ); - return ResponseEntity.ok(newPerformanceHall) + ) + return ResponseEntity.status(HttpStatus.CREATED).body(newPerformanceHall) } @DeleteMapping("/admin/v1/performance-hall/{performanceHallId}") fun deletePerformance( - @PathVariable performanceHallId: String + @PathVariable performanceHallId: String, + @AuthUser user: User ): ResponseEntity { + // UserIdentity를 통해 역할(Role) 확인 + val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 + ?: throw UserIdentityNotFoundException() + + if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) + } + performanceHallService.deletePerformanceHall(performanceHallId) return ResponseEntity.noContent().build() } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventRepository.kt index 859c204..7adad1f 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventRepository.kt @@ -3,5 +3,5 @@ package com.wafflestudio.interpark.performance.persistence import org.springframework.data.jpa.repository.JpaRepository interface PerformanceEventRepository : JpaRepository { - + fun findAllByPerformanceId(performanceId: String): List } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallRepository.kt index 8862e05..8ab6262 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallRepository.kt @@ -3,5 +3,7 @@ package com.wafflestudio.interpark.performance.persistence import org.springframework.data.jpa.repository.JpaRepository interface PerformanceHallRepository : JpaRepository { - + fun findByName(name: String): PerformanceHallEntity? + + fun existsByName(name: String): Boolean } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceRepository.kt index 89ffae2..da63de4 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceRepository.kt @@ -6,5 +6,5 @@ import org.springframework.data.jpa.repository.JpaSpecificationExecutor interface PerformanceRepository : JpaRepository, JpaSpecificationExecutor { - + fun findByTitle(title: String): PerformanceEntity? } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt index ac970d8..4416584 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt @@ -8,6 +8,8 @@ import org.springframework.stereotype.Service import org.springframework.data.repository.findByIdOrNull import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId @Service class PerformanceEventService( @@ -20,26 +22,34 @@ class PerformanceEventService( .findAll() .map { PerformanceEvent.fromEntity(it) }; } - + + fun parseKoreanTimeToInstant(koreanTime: String): Instant { + val koreanZone = ZoneId.of("Asia/Seoul") + return LocalDateTime.parse(koreanTime).atZone(koreanZone).toInstant() + } + fun createPerformanceEvent( performanceId: String, performanceHallId: String, - startAt: Instant, - endAt: Instant, + startAt: String, + endAt: String, ): PerformanceEvent { - val performanceEntity: PerformanceEntity = performanceRepository.findByIdOrNull(performanceId) ?: throw PerformanceNotFoundException() - val performanceHallEntity: PerformanceHallEntity = performanceHallRepository.findByIdOrNull(performanceHallId) ?: throw PerformanceHallNotFoundException() + val performanceEntity: PerformanceEntity = performanceRepository.findByIdOrNull(performanceId) + ?: throw PerformanceNotFoundException() + val performanceHallEntity: PerformanceHallEntity = performanceHallRepository.findByIdOrNull(performanceHallId) + ?: throw PerformanceHallNotFoundException() val newPerformanceEventEntity: PerformanceEventEntity = PerformanceEventEntity( id = "", performance = performanceEntity, performanceHall = performanceHallEntity, - startAt = startAt, - endAt = endAt, + startAt = parseKoreanTimeToInstant(startAt), + endAt = parseKoreanTimeToInstant(endAt), ).let{ performanceEventRepository.save(it) } return PerformanceEvent.fromEntity(newPerformanceEventEntity) } + fun deletePerformanceEvent(performanceEventId: String) { val deletePerformanceEventEntity: PerformanceEventEntity = performanceEventRepository.findByIdOrNull(performanceEventId) ?: throw PerformanceEventNotFoundException() diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceHallService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceHallService.kt index 29ff809..7e95d25 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceHallService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceHallService.kt @@ -22,6 +22,10 @@ class PerformanceHallService( address: String, maxAudience: Int, ): PerformanceHall { + if (performanceHallRepository.existsByName(name)) { + throw PerformanceHallNameConflictException() + } + val newPerformanceHallEntity: PerformanceHallEntity = PerformanceHallEntity( id = "", name = name, diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt index 59f0e94..029ad82 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt @@ -1,7 +1,10 @@ package com.wafflestudio.interpark.performance.service import com.wafflestudio.interpark.performance.PerformanceNotFoundException +import com.wafflestudio.interpark.performance.controller.BriefPerformanceDetail import com.wafflestudio.interpark.performance.controller.Performance +import com.wafflestudio.interpark.performance.controller.PerformanceEvent +import com.wafflestudio.interpark.performance.controller.PerformanceHall import com.wafflestudio.interpark.performance.persistence.* import org.springframework.data.jpa.domain.Specification import org.springframework.stereotype.Service @@ -10,11 +13,12 @@ import org.springframework.data.repository.findByIdOrNull @Service class PerformanceService( private val performanceRepository: PerformanceRepository, + private val performanceEventRepository: PerformanceEventRepository, ) { fun searchPerformance( title: String?, category: PerformanceCategory?, - ): List { + ): List { // 시작점: 아무 조건이 없는 스펙 var spec: Specification = Specification.where(null) @@ -31,14 +35,51 @@ class PerformanceService( // 스펙이 결국 아무 조건도 없으면 -> 전체 검색 val performanceEntities = performanceRepository.findAll(spec) - // DTO 변환 - return performanceEntities.map { Performance.fromEntity(it) } + // BriefDetail DTO 변환 + return performanceEntities.map { performanceEntity -> + val performanceEventEntities = performanceEventRepository.findAllByPerformanceId(performanceEntity.id!!) + val performanceEvents = if (performanceEventEntities.isEmpty()) { + null + } else { + performanceEventEntities.map { PerformanceEvent.fromEntity(it) } + } + val performanceHall = performanceEventEntities.firstOrNull()?.let { + PerformanceHall.fromEntity(it.performanceHall) + } + + Performance.fromEntityToBriefDetails( + performanceEntity = performanceEntity, + performanceHall = performanceHall, + performanceEvents = performanceEvents + ) + } } fun getAllPerformance(): List { return performanceRepository .findAll() - .map { Performance.fromEntity(it) }; + .map { performanceEntity -> + val performanceEventEntities = performanceEventRepository.findAllByPerformanceId(performanceEntity.id!!) + val performanceEvents = performanceEventEntities.map{ PerformanceEvent.fromEntity(it) } + val performanceHall = PerformanceHall.fromEntity(performanceEventEntities.first().performanceHall) + + Performance.fromEntity( + performanceEntity = performanceEntity, + performanceHall = performanceHall, + performanceEvents = performanceEvents + ) + } + } + + fun getPerformanceDetail(performanceId: String): Performance { + val performanceEntity: PerformanceEntity = performanceRepository.findByIdOrNull(performanceId) ?: throw PerformanceNotFoundException() + val performanceEventEntities = performanceEventRepository.findAllByPerformanceId(performanceEntity.id!!) + val performanceEvents = performanceEventEntities.map{ PerformanceEvent.fromEntity(it) } + val performanceHall = performanceEventEntities.firstOrNull()?.let { + PerformanceHall.fromEntity(it.performanceHall) + } + + return Performance.fromEntity(performanceEntity, performanceEvents, performanceHall) } fun createPerformance( @@ -58,7 +99,7 @@ class PerformanceService( ).let{ performanceRepository.save(it) } - return Performance.fromEntity(newPerformanceEntity) + return Performance.fromEntity(newPerformanceEntity, null, null) } fun deletePerformance(performanceId: String) { diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt b/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt index f0dd1ab..f9ab9b1 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt @@ -41,6 +41,12 @@ class SignInInvalidPasswordException : UserException( msg = "Invalid Password", ) +class UserIdentityNotFoundException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.NOT_FOUND, + msg = "UserIdentity not found", +) + class AuthenticateException : UserException( errorCode = 0, httpStatusCode = HttpStatus.UNAUTHORIZED, diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt index 9c8fc33..a388b1d 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt @@ -1,6 +1,7 @@ package com.wafflestudio.interpark.user.controller import com.wafflestudio.interpark.user.* +import com.wafflestudio.interpark.user.persistence.UserRole import com.wafflestudio.interpark.user.service.UserService import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.media.Content @@ -88,11 +89,12 @@ class UserController( ): ResponseEntity { val user = userService.signUp( - request.username, - request.password, - request.nickname, - request.email, - request.phoneNumber, + username = request.username, + password = request.password, + nickname = request.nickname, + email = request.email, + phoneNumber = request.phoneNumber, + role = request.role, ) return ResponseEntity.ok(SignUpResponse(user)) } @@ -165,6 +167,7 @@ data class SignUpRequest( val nickname: String, val phoneNumber: String, val email: String, + val role: UserRole = UserRole.USER, ) data class SignUpResponse(val user: User) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt index eef18d9..f6c15e2 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt @@ -17,7 +17,7 @@ class UserIdentityEntity( @JoinColumn(name = "user_id") var user: UserEntity, @Column(name = "role", nullable = false) - var role: String, + var role: UserRole = UserRole.USER, @Column(name = "hashed_password", nullable = false) val hashedPassword: String, @Column(name = "provider", nullable = false) @@ -25,3 +25,7 @@ class UserIdentityEntity( @Column(name = "social_id", nullable = true) val socialId: String? = null, ) + +enum class UserRole { + USER, ADMIN +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityRepository.kt index 057b511..628636f 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityRepository.kt @@ -4,4 +4,5 @@ import org.springframework.data.jpa.repository.JpaRepository interface UserIdentityRepository : JpaRepository { fun findByUser(user: UserEntity): UserIdentityEntity? + fun findByUserId(userId: String): UserIdentityEntity? } diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt index f2fb16a..fd774c5 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt @@ -6,6 +6,7 @@ import com.wafflestudio.interpark.user.persistence.UserEntity import com.wafflestudio.interpark.user.persistence.UserIdentityEntity import com.wafflestudio.interpark.user.persistence.UserIdentityRepository import com.wafflestudio.interpark.user.persistence.UserRepository +import com.wafflestudio.interpark.user.persistence.UserRole import org.mindrot.jbcrypt.BCrypt import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service @@ -24,6 +25,7 @@ class UserService( nickname: String, phoneNumber: String, email: String, + role: UserRole = UserRole.USER, ): User { if (username.length < 6 || username.length > 20) { throw SignUpBadUsernameException() @@ -47,7 +49,7 @@ class UserService( userIdentityRepository.save( UserIdentityEntity( user = user, - role = "USER", + role = role, hashedPassword = encryptedPassword, provider = "self", ), @@ -86,4 +88,8 @@ class UserService( fun refreshAccessToken(refreshToken: String): Pair { return userAccessTokenUtil.refreshAccessToken(refreshToken) ?: throw AuthenticateException() } + + fun getUserIdentityByUserId(userId: String): UserIdentityEntity? { + return userIdentityRepository.findByUserId(userId) + } } diff --git a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt new file mode 100644 index 0000000..ef8de17 --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt @@ -0,0 +1,258 @@ +package com.wafflestudio.interpark + +import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.interpark.performance.persistence.PerformanceCategory +import com.wafflestudio.interpark.user.persistence.UserRole +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Transactional +class PerformanceIntegrationTest +@Autowired +constructor( + private val mvc: MockMvc, + private val mapper: ObjectMapper, +) { + private lateinit var userAccessToken: String + private lateinit var adminAccessToken: String + private lateinit var performanceId: String + + @BeforeEach + fun setUp() { + val username = UUID.randomUUID().toString().take(8) + val adminname = UUID.randomUUID().toString().takeLast(8) + val password = "password123" + + // 1️⃣ 회원가입 + // 일반 유저 + mvc.perform( + post("/api/v1/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + "nickname" to "test_user", + "phoneNumber" to "010-0000-0000", + "email" to "test@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + // 관리자 + mvc.perform( + post("/api/v1/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to adminname, + "password" to password, + "nickname" to "test_admin", + "phoneNumber" to "010-0000-0000", + "email" to "test@example.com", + "role" to UserRole.ADMIN, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + // 2️⃣ 로그인 → 토큰 획득 + // 일반 유저 + userAccessToken = + mvc.perform( + post("/api/v1/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + // 관리자 + adminAccessToken = + mvc.perform( + post("/api/v1/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to adminname, + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + // 3️⃣ 테스트용 공연 ID 반환 + performanceId = + mvc.perform( + get("/api/v1/performance/search") + .header("Authorization", "Bearer $userAccessToken") + .param("title", "지킬앤하이드") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val node = mapper.readTree(it) + val firstItem = node.firstOrNull() ?: error("Response array is empty") + val idNode = firstItem.get("id") + requireNotNull(idNode) { "ID not found in response item: $firstItem" } + idNode.asText() + } + } + + @Test + fun `공연 검색 플로우 테스트`() { + // 4️⃣ 공연 검색 (title 조건) + mvc.perform( + get("/api/v1/performance/search") + .header("Authorization", "Bearer $userAccessToken") + .param("title", "지킬앤하이드") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[0].title").value("뮤지컬 지킬앤하이드")) + .andExpect(jsonPath("$.length()").value(1)) + + // 5️⃣ 공연 검색 (category 조건) + mvc.perform( + get("/api/v1/performance/search") + .header("Authorization", "Bearer $userAccessToken") + .param("category", PerformanceCategory.CONCERT.name) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$").isArray) // 응답이 배열인지 확인 + .andExpect(jsonPath("$.length()").value(3)) // 배열의 길이가 0인지 확인 + } + + @Test + fun `공연 상세 조회 테스트`() { + // 6️⃣ 공연 상세 조회 + println("Generated performanceId: $performanceId") + mvc.perform( + get("/api/v1/performance/$performanceId") + .header("Authorization", "Bearer $userAccessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$.id").value(performanceId)) + .andExpect(jsonPath("$.title").value("뮤지컬 지킬앤하이드")) + } + + @Test + fun `공연 삭제 플로우 테스트`() { + // 일반 유저 공연 삭제 실패 + mvc.perform( + delete("/admin/v1/performance/$performanceId") + .header("Authorization", "Bearer $userAccessToken"), + ).andExpect(status().`is`(403)) + + // 7️⃣ 공연 삭제 + mvc.perform( + delete("/admin/v1/performance/$performanceId") + .header("Authorization", "Bearer $adminAccessToken"), + ).andExpect(status().`is`(204)) + + // 8️⃣ 삭제된 공연 상세 조회 실패 확인 + mvc.perform( + get("/api/v1/performance/$performanceId") + .header("Authorization", "Bearer $adminAccessToken"), + ).andExpect(status().`is`(404)) + .andExpect(jsonPath("$.error").value("Performance Not Found")) + } + + @Test + fun `공연 생성 테스트 - 관리자 성공`() { + // 1️⃣ 공연 생성 요청 데이터 + val createPerformanceRequest = mapOf( + "title" to "뮤지컬 캣츠", + "detail" to "https://example.com/cats-detail.jpg", + "category" to PerformanceCategory.MUSICAL.name, + "posterUri" to "https://example.com/cats-poster.jpg", + "backdropImageUri" to "https://example.com/cats-backdrop.jpg" + ) + + // 2️⃣ 공연 생성 요청 및 응답 확인 + val result = mvc.perform( + post("/admin/v1/performance") + .header("Authorization", "Bearer $adminAccessToken") + .content(mapper.writeValueAsString(createPerformanceRequest)) + .contentType(MediaType.APPLICATION_JSON), + ) + .andExpect(status().`is`(201)) // HTTP 201 Created 확인 + .andExpect(jsonPath("$.title").value("뮤지컬 캣츠")) + .andExpect(jsonPath("$.detail").value("https://example.com/cats-detail.jpg")) + .andExpect(jsonPath("$.category").value(PerformanceCategory.MUSICAL.name)) + .andExpect(jsonPath("$.posterUri").value("https://example.com/cats-poster.jpg")) + .andExpect(jsonPath("$.backdropImageUri").value("https://example.com/cats-backdrop.jpg")) + .andReturn() + } + + @Test + fun `공연 생성 테스트 - 일반 유저 실패`() { + // 1️⃣ 공연 생성 요청 데이터 + val createPerformanceRequest = mapOf( + "title" to "뮤지컬 캣츠", + "detail" to "https://example.com/cats-detail.jpg", + "category" to PerformanceCategory.MUSICAL.name, + "posterUri" to "https://example.com/cats-poster.jpg", + "backdropImageUri" to "https://example.com/cats-backdrop.jpg" + ) + + // 2️⃣ 공연 생성 요청 및 응답 확인 + val result = mvc.perform( + post("/admin/v1/performance") + .header("Authorization", "Bearer $userAccessToken") + .content(mapper.writeValueAsString(createPerformanceRequest)) + .contentType(MediaType.APPLICATION_JSON), + ) + .andExpect(status().`is`(403)) // HTTP 403 Forbidden 확인 + } + + @Test + fun `공연 생성 실패 - 필수 정보 누락`() { + mvc.perform( + post("/admin/v1/performance") + .header("Authorization", "Bearer $adminAccessToken") + .content( + mapper.writeValueAsString( + mapOf( + "title" to "", + "detail" to "지금 이 순간, 끝나지 않는 신화", + "category" to PerformanceCategory.MUSICAL.name, + "posterUri" to "", + "backdropImageUri" to "", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(400)) + .andExpect(jsonPath("$.error").value("Method Argument Validation failed")) + } +} + \ No newline at end of file diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt index 476c4da..92ad67d 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt @@ -16,7 +16,7 @@ import java.util.UUID @AutoConfigureMockMvc @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Transactional -class ReplyControllerTest +class ReplyIntegrationTest @Autowired constructor( private val mvc: MockMvc, diff --git a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt index 3e6499c..283f1de 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt @@ -12,7 +12,8 @@ import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* import org.springframework.transaction.annotation.Transactional -import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId import java.util.UUID @AutoConfigureMockMvc @@ -85,7 +86,7 @@ constructor( } performanceId = mvc.perform( - get("/v1/performance/search") + get("/api/v1/performance/search") ).andExpect(status().`is`(200)) .andReturn() .response @@ -102,8 +103,8 @@ constructor( mapOf( "performanceId" to performanceId, "performanceHallId" to performanceHallId, - "startAt" to Instant.now(), - "endAt" to Instant.now(), + "startAt" to LocalDateTime.now(ZoneId.of("Asia/Seoul")), + "endAt" to LocalDateTime.now(ZoneId.of("Asia/Seoul")) ), ), ) From b32f788e101b5fd98de7806c267e08efc8384e0b Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sun, 19 Jan 2025 17:29:15 +0900 Subject: [PATCH 052/162] applying spring security --- build.gradle.kts | 2 +- .../config/CustomAuthenticationEntryPoint.kt | 24 +++++++ .../interpark/config/SecurityConfig.kt | 48 ++++++++++++++ .../interpark/config/SwaggerConfig.kt | 62 +++++++++++++++++++ .../interpark/user/JwtAuthenticationFilter.kt | 59 ++++++++++++++++++ .../user/controller/UserController.kt | 2 +- .../user/controller/UserDetailsImpl.kt | 35 +++++++++++ .../user/persistence/UserIdentityEntity.kt | 9 ++- .../persistence/UserIdentityRepository.kt | 1 + .../user/service/UserDetailsServiceImpl.kt | 25 ++++++++ 10 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/com/wafflestudio/interpark/config/CustomAuthenticationEntryPoint.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/config/SecurityConfig.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/config/SwaggerConfig.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/user/JwtAuthenticationFilter.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt diff --git a/build.gradle.kts b/build.gradle.kts index 231e961..7afc0ba 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,7 +21,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-validation") - //implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-security") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.mindrot:jbcrypt:0.4") diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/CustomAuthenticationEntryPoint.kt b/src/main/kotlin/com/wafflestudio/interpark/config/CustomAuthenticationEntryPoint.kt new file mode 100644 index 0000000..2da548b --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/config/CustomAuthenticationEntryPoint.kt @@ -0,0 +1,24 @@ +package com.wafflestudio.interpark.config + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.stereotype.Component + +@Component +class CustomAuthenticationEntryPoint : AuthenticationEntryPoint { + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + authException: AuthenticationException + ) { + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.contentType = "application/json" + response.writer.write( + """ + { "error": "Unauthorized", "message": "${authException.message}" } + """.trimIndent() + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/SecurityConfig.kt b/src/main/kotlin/com/wafflestudio/interpark/config/SecurityConfig.kt new file mode 100644 index 0000000..46bb580 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/config/SecurityConfig.kt @@ -0,0 +1,48 @@ +package com.wafflestudio.interpark.config + +import com.wafflestudio.interpark.user.JwtAuthenticationFilter +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.config.annotation.web.invoke +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +@Configuration +@EnableWebSecurity +class SecurityConfig ( + private val jwtAuthenticationFilter: JwtAuthenticationFilter, + private val customAuthenticationEntryPoint: CustomAuthenticationEntryPoint +) { + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + cors { disable() } + csrf { disable() } + sessionManagement { + sessionCreationPolicy = SessionCreationPolicy.STATELESS // 세션 비활성화 + } + authorizeHttpRequests { + // 사용자 권한 + authorize("/api/v1/**", permitAll) + authorize("/admin/v1/**", hasRole("ADMIN")) + //authorize("/admin/v1/**").hasRole("ADMIN") + + // Swagger 관련 경로 허용 + authorize("/swagger-ui/**", permitAll) + authorize("/v3/api-docs/**", permitAll) + authorize("/swagger-resources/**", permitAll) + authorize("/webjars/**", permitAll) + + authorize(anyRequest, authenticated) + } + exceptionHandling { + authenticationEntryPoint = customAuthenticationEntryPoint // 인증 실패 처리 + } + addFilterBefore(jwtAuthenticationFilter) + } + return http.build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/SwaggerConfig.kt b/src/main/kotlin/com/wafflestudio/interpark/config/SwaggerConfig.kt new file mode 100644 index 0000000..9813b0a --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/config/SwaggerConfig.kt @@ -0,0 +1,62 @@ +package com.wafflestudio.interpark.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.security.OAuthFlow +import io.swagger.v3.oas.models.security.OAuthFlows +import io.swagger.v3.oas.models.security.Scopes +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 +class SwaggerConfig { + + @Bean + fun openAPI(): OpenAPI { + return OpenAPI() + .info( + Info() + .title("Interpark Ticket API") + .version("v1") + .description( + """ + API documentation for Interpark Ticket application. + - Supports JWT-based local authentication. + - Supports OAuth2.0 for social login (Google, Naver). + """.trimIndent() + ) + ) + .addSecurityItem(SecurityRequirement().addList("Bearer Authentication")) + .addSecurityItem(SecurityRequirement().addList("Google OAuth2")) + .components( + Components() + .addSecuritySchemes( + "Bearer Authentication", + SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + ) + .addSecuritySchemes( + "Google OAuth2", + SecurityScheme() + .type(SecurityScheme.Type.OAUTH2) + .flows( + OAuthFlows() + .authorizationCode( + OAuthFlow() + .authorizationUrl("https://accounts.google.com/o/oauth2/auth") + .tokenUrl("https://oauth2.googleapis.com/token") + .scopes( + Scopes() + .addString("email", "email access") + ) + ) + ) + ) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/JwtAuthenticationFilter.kt b/src/main/kotlin/com/wafflestudio/interpark/user/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..7b0b410 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/user/JwtAuthenticationFilter.kt @@ -0,0 +1,59 @@ +package com.wafflestudio.interpark.user + +import com.wafflestudio.interpark.user.service.UserDetailsServiceImpl +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.web.filter.OncePerRequestFilter +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component + +@Component +class JwtAuthenticationFilter( + private val userAccessTokenUtil: UserAccessTokenUtil, + private val userDetailsService: UserDetailsServiceImpl +) : OncePerRequestFilter() { + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + chain: FilterChain + ) { + // 1) 헤더에서 "Authorization" 값 추출 + val header = request.getHeader("Authorization") + // 예: "Authorization: Bearer " + if (!header.isNullOrBlank() && header.startsWith("Bearer ")) { + val accessToken = header.split(" ")[1] + + // 2) UserAccessTokenUtil로 토큰 유효성 검사 + // 유효하면 userId가 반환되고, + // 유효하지 않으면 null + val subject = userAccessTokenUtil.validateAccessToken(accessToken) + + if (subject != null) { + // 3) subject를 기반으로 DB에서 유저/권한 정보 조회 (UserIdentityEntity) + val identity = userDetailsService.getUserIdentityByUserId(subject) + + if (identity != null) { + // 예: role -> SimpleGrantedAuthority("ROLE_USER"/"ROLE_ADMIN" 등) + val roleName = "ROLE_${identity.role.name}" // e.g. ROLE_USER + val authorities = listOf(SimpleGrantedAuthority(roleName)) + + // 4) Spring Security에 Authentication 등록 + // principal에는 identity(또는 더 확장된 CustomUserDetails)를 넣어도 됨 + val authentication = UsernamePasswordAuthenticationToken( + identity, // principal + null, // credentials + authorities + ) + SecurityContextHolder.getContext().authentication = authentication + } + } + } + + // 5) 체인 계속 진행 + chain.doFilter(request, response) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt index 45687cd..8181823 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt @@ -25,7 +25,6 @@ class UserController( return ResponseEntity.ok(mapOf("message" to "pong")) } - @PostMapping("/api/v1/local/signup") @Operation( summary = "사용자 회원가입", description = """ @@ -84,6 +83,7 @@ class UserController( )] ) ) + @PostMapping("/api/v1/local/signup") fun signup( @RequestBody request: SignUpRequest, ): ResponseEntity { diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt new file mode 100644 index 0000000..2247b6b --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt @@ -0,0 +1,35 @@ +package com.wafflestudio.interpark.user.controller + +import com.wafflestudio.interpark.user.persistence.UserEntity +import com.wafflestudio.interpark.user.persistence.UserIdentityEntity +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails + +class UserDetailsImpl ( + private val userIdentityEntity: UserIdentityEntity +) : UserDetails { + override fun getUsername(): String { + return userIdentityEntity.user.username + } + + override fun getPassword(): String { + return userIdentityEntity.hashedPassword + } + + override fun getAuthorities(): MutableCollection { + val roleName = "ROLE_${userIdentityEntity.role.name}" // ROLE_USER / ROLE_ADMIN + return mutableListOf(SimpleGrantedAuthority(roleName)) + } + + override fun isAccountNonExpired(): Boolean = true + override fun isAccountNonLocked(): Boolean = true + override fun isCredentialsNonExpired(): Boolean = true + override fun isEnabled(): Boolean = true + + // 필요하면 convenience 메서드 + fun getUserId(): String? = userIdentityEntity.user.id + fun getNickname(): String = userIdentityEntity.user.nickname + fun getEmail(): String = userIdentityEntity.user.email + fun getAddress(): String? = userIdentityEntity.user.address +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt index f6c15e2..56c5852 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt @@ -7,6 +7,7 @@ import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.OneToOne +import org.springframework.security.core.GrantedAuthority @Entity class UserIdentityEntity( @@ -26,6 +27,10 @@ class UserIdentityEntity( val socialId: String? = null, ) -enum class UserRole { - USER, ADMIN +enum class UserRole : GrantedAuthority { + USER, ADMIN; + + override fun getAuthority(): String { + return "ROLE_$name" // Spring Security에서 권장하는 ROLE_ 접두사 + } } \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityRepository.kt index 628636f..6636bb0 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityRepository.kt @@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository interface UserIdentityRepository : JpaRepository { fun findByUser(user: UserEntity): UserIdentityEntity? fun findByUserId(userId: String): UserIdentityEntity? + fun findByUserUsername(username: String): UserIdentityEntity? } diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt new file mode 100644 index 0000000..4b95c25 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt @@ -0,0 +1,25 @@ +package com.wafflestudio.interpark.user.service + +import com.wafflestudio.interpark.user.UserIdentityNotFoundException +import com.wafflestudio.interpark.user.controller.UserDetailsImpl +import com.wafflestudio.interpark.user.persistence.UserIdentityEntity +import com.wafflestudio.interpark.user.persistence.UserIdentityRepository +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.stereotype.Service + +@Service +class UserDetailsServiceImpl ( + private val userIdentityRepository: UserIdentityRepository, +) : UserDetailsService { + override fun loadUserByUsername(username: String): UserDetails { + val userIdentityEntity = userIdentityRepository.findByUserUsername(username) + ?: throw UserIdentityNotFoundException() + + return UserDetailsImpl(userIdentityEntity) + } + + fun getUserIdentityByUserId(userId: String): UserIdentityEntity? { + return userIdentityRepository.findByUserId(userId) + } +} \ No newline at end of file From 011c9d6bc91ede1e4a3145e2a8afe02538c1575d Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sun, 19 Jan 2025 18:00:01 +0900 Subject: [PATCH 053/162] setup .env --- .env | 3 +++ .gitignore | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 0000000..e45ba25 --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +SPRING_DATASOURCE_URL: "jdbc:mysql://mysql-db:3306/testdb" +SPRING_DATASOURCE_USERNAME: "user" +SPRING_DATASOURCE_PASSWORD: "somepassword" \ No newline at end of file diff --git a/.gitignore b/.gitignore index b1dff0d..9665d0e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ out/ !**/src/main/**/out/ !**/src/test/**/out/ +# .env +.env + ### Kotlin ### .kotlin From ab583232e7fd888d8f46300a9619581c3c04ccef Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sun, 19 Jan 2025 18:11:30 +0900 Subject: [PATCH 054/162] feat: Find PerformanceEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PerformanceId와 LocalDate로부터 PerformanceEventId를 반환하도록 추가 잘못된 PerformanceEventId로 빈좌석정보를 확인했을 때 에러 반환 --- .../controller/PerformanceEventController.kt | 17 +++++++++ .../persistence/PerformanceEventRepository.kt | 13 +++++++ .../service/PerformanceEventService.kt | 16 +++++++++ .../interpark/seat/service/SeatService.kt | 5 ++- src/test/resources/SeatApi.http | 36 +++++++++++++++++++ 5 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 src/test/resources/SeatApi.http diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt index 87e59d4..091ec45 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt @@ -9,6 +9,8 @@ import com.wafflestudio.interpark.user.service.UserService import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* +import java.time.LocalDate +import java.time.format.DateTimeFormatter @RestController class PerformanceEventController( @@ -24,6 +26,21 @@ class PerformanceEventController( .getAllPerformanceEvent() return ResponseEntity.ok(performanceEventList) } + + @GetMapping("/api/v1/performance-event/{performanceId}/{performanceDate}") + fun getPerformanceEventFromDate( + @AuthUser user: User, + @PathVariable performanceId: String, + @PathVariable performanceDate: String, + ): ResponseEntity { + val localPerformanceDate = LocalDate.parse(performanceDate) + val performanceEventList = performanceEventService.getPerformanceEventFromDate( + performanceId = performanceId, + performanceDate = localPerformanceDate, + ) + + return ResponseEntity.ok(performanceEventList) + } // WARN: THIS IS FOR ADMIN. // TODO: SEPERATE THIS TO OTHER APPLICATION diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventRepository.kt index 7adad1f..5288023 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventRepository.kt @@ -1,7 +1,20 @@ package com.wafflestudio.interpark.performance.persistence import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import java.time.Instant interface PerformanceEventRepository : JpaRepository { fun findAllByPerformanceId(performanceId: String): List + + @Query( + """ + SELECT e + FROM PerformanceEventEntity e + WHERE e.performance.id = :performanceId + AND e.startAt >= :startTime + AND e.startAt < :endTime + """ + ) + fun findByPerformanceIdAndDate(performanceId: String, startTime: Instant, endTime: Instant): List } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt index 4ebec8f..75c1ab4 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt @@ -11,6 +11,7 @@ import org.springframework.stereotype.Service import org.springframework.data.repository.findByIdOrNull import java.time.Instant +import java.time.LocalDate import java.time.LocalDateTime import java.time.ZoneId @@ -27,6 +28,21 @@ class PerformanceEventService( .map { PerformanceEvent.fromEntity(it) }; } + fun getPerformanceEventFromDate( + performanceId: String, + performanceDate: LocalDate, + ): List { + val startOfDate = performanceDate.atStartOfDay().atZone(ZoneId.of("Asia/Seoul")).toInstant() + val endOfDate = performanceDate.plusDays(1).atStartOfDay().atZone(ZoneId.of("Asia/Seoul")).toInstant() + return performanceEventRepository + .findByPerformanceIdAndDate( + performanceId = performanceId, + startTime = startOfDate, + endTime = endOfDate, + ) + .map { PerformanceEvent.fromEntity(it) }; + } + fun parseKoreanTimeToInstant(koreanTime: String): Instant { val koreanZone = ZoneId.of("Asia/Seoul") return LocalDateTime.parse(koreanTime).atZone(koreanZone).toInstant() diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt index ca7c83f..1f1a912 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt @@ -1,5 +1,7 @@ package com.wafflestudio.interpark.seat.service +import com.wafflestudio.interpark.performance.PerformanceEventNotFoundException +import com.wafflestudio.interpark.performance.persistence.PerformanceEventRepository import com.wafflestudio.interpark.seat.ReservationNotFoundException import com.wafflestudio.interpark.seat.ReservationPermissionDeniedException import com.wafflestudio.interpark.seat.ReservedAlreadyException @@ -20,11 +22,12 @@ import java.time.LocalDate @Service class SeatService( private val reservationRepository: ReservationRepository, - private val seatRepository: SeatRepository, + private val performanceEventRepository: PerformanceEventRepository, private val userRepository: UserRepository, ) { @Transactional fun getAvailableSeats(performanceEventId: String): List> { + performanceEventRepository.findByIdOrNull(performanceEventId) ?: throw PerformanceEventNotFoundException() val availableReservations = reservationRepository.findByPerformanceEventIdAndReservedIsFalse(performanceEventId) val availableSeats = availableReservations.map { Pair(it.id!!, Seat.fromEntity(it.seat)) } return availableSeats diff --git a/src/test/resources/SeatApi.http b/src/test/resources/SeatApi.http new file mode 100644 index 0000000..c232081 --- /dev/null +++ b/src/test/resources/SeatApi.http @@ -0,0 +1,36 @@ +### 회원가입 +POST http://localhost:8080/api/v1/local/signup +Content-Type: application/json + +{ + "username": "good_name", + "password": "12345678", + "nickname": "examplename", + "phoneNumber": "010-0000-0000", + "email": "test@example.com" +} + +### 로그인 +POST http://localhost:8080/api/v1/local/signin +Content-Type: application/json + +{ + "username": "good_name", + "password": "12345678" +} + +### performance 받기 +GET http://localhost:8080/api/v1/performance/search +Accept: application/json + +### event 찾기 +GET http://localhost:8080/api/v1/performance-event/e1656490-a5be-446e-b119-d8efa393dd05/2025-01-11 +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkODUyNjcwMy0zM2Y2LTRmM2ItYTdhMC1mOGZlOGQ3NTcxNDAiLCJpYXQiOjE3MzcyNzcyMjQsImV4cCI6MTczNzI3ODEyNH0.KObDWrRBDRZU-iNyFHHherkbxIjigmjpVn-Iv5Lx3yY + +### event 받기 +GET http://localhost:8080/api/v1/performance-event +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkODUyNjcwMy0zM2Y2LTRmM2ItYTdhMC1mOGZlOGQ3NTcxNDAiLCJpYXQiOjE3MzcyNzcyMjQsImV4cCI6MTczNzI3ODEyNH0.KObDWrRBDRZU-iNyFHHherkbxIjigmjpVn-Iv5Lx3yY + +### 가능한 좌석 받기 +GET http://localhost:8080/api/v1/seat/eade9900-a141-4532-bd2c-c362ceb7c8c0/available +Accept: application/json \ No newline at end of file From f09a1b68d6b1982c234a0e7e5df7fddc9f64dda2 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sun, 19 Jan 2025 20:38:42 +0900 Subject: [PATCH 055/162] =?UTF-8?q?chore:=20.env=20=EB=AC=B4=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b1dff0d..d849736 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,6 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store + +.env \ No newline at end of file From bf6b0ca7ee5ed134dc408de3f3c6e73b524cf408 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Mon, 20 Jan 2025 01:05:07 +0900 Subject: [PATCH 056/162] feat: Reinforce Simultaneous test code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 동시에 여러 사람의 접근이 있어도 통과하는지 테스트 코드 추가 동시에 여러 사람이 서로 다른 좌석에 접근할 때 테스트 코드 추가 --- .../interpark/SimultaneousTest.kt | 198 +++++++++++++++++- 1 file changed, 196 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt index 989de33..445c67a 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt @@ -97,8 +97,8 @@ constructor( } val results = mutableListOf() - var successCnt = AtomicInteger(0) - var conflictCnt = AtomicInteger(0) + val successCnt = AtomicInteger(0) + val conflictCnt = AtomicInteger(0) val tasks = (1..10).map { threadPool.submit { val responseStatus = mvc.perform( @@ -122,4 +122,198 @@ constructor( assert(successCnt.get() == 1) {"expected 1 success but ${successCnt.get()}"} assert(conflictCnt.get() == 9) {"expected 9 conflict but ${conflictCnt.get()}"} } + + @Test + fun `한 예매에 동시에 여러명이 접속해도 하나만 통과한다`() { + val threadPool = Executors.newFixedThreadPool(10) + val username = (0..9).map { UUID.randomUUID().toString().take(16) } + val password = "password123" + + // 1️⃣ 회원가입 + (0..9).map { + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username[it], + "password" to password, + "nickname" to "reviewer", + "phoneNumber" to "010-0000-0000", + "email" to "reviewer@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + } + + // 2️⃣ 로그인 → 토큰 획득 + val accessToken = + (0..9).map { + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username[it], + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + } + + val performanceEventId = + mvc.perform( + get("/api/v1/performance-event") + .header("Authorization", "Bearer ${accessToken[0]}"), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performanceEvents = mapper.readTree(it) + performanceEvents[0].get("id").asText() + } + + val reservationId = mvc.perform( + get("/api/v1/seat/$performanceEventId/available") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val availableSeats = mapper.readTree(it).get("availableSeats") + availableSeats[0].get("reservationId").asText() + } + + val results = mutableListOf() + val successCnt = AtomicInteger(0) + val conflictCnt = AtomicInteger(0) + val tasks = (0..9).map { + threadPool.submit { + val responseStatus = mvc.perform( + post("/api/v1/reservation/reserve") + .content( + mapper.writeValueAsString( + mapOf( + "reservationId" to reservationId, + ), + ), + ) + .header("Authorization", "Bearer ${accessToken[it]}") + .contentType(MediaType.APPLICATION_JSON) + ).andReturn().response.status + results.add(responseStatus) + if (responseStatus == 200) { successCnt.incrementAndGet() } + if (responseStatus == 409) { conflictCnt.incrementAndGet() } + } + } + tasks.forEach { it.get() } + assert(successCnt.get() == 1) {"expected 1 success but ${successCnt.get()}"} + assert(conflictCnt.get() == 9) {"expected 9 conflict but ${conflictCnt.get()}"} + } + + @Test + fun `동시에 다른 좌석에 접근하면 모두 잘 처리된다`() { + val threadPool = Executors.newFixedThreadPool(10) + val username = (0..9).map { UUID.randomUUID().toString().take(15) } + val password = "password123" + + // 1️⃣ 회원가입 + (0..9).map { + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username[it], + "password" to password, + "nickname" to "reviewer", + "phoneNumber" to "010-0000-0000", + "email" to "reviewer@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + } + + // 2️⃣ 로그인 → 토큰 획득 + val accessToken = + (0..9).map { + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username[it], + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + } + + val performanceEventId = + mvc.perform( + get("/api/v1/performance-event") + .header("Authorization", "Bearer ${accessToken[0]}"), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performanceEvents = mapper.readTree(it) + performanceEvents[0].get("id").asText() + } + + val reservationId = mvc.perform( + get("/api/v1/seat/$performanceEventId/available") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val availableSeats = mapper.readTree(it).get("availableSeats") + (1..10).map { availableSeats[it].get("reservationId").asText() } + } + + val results = mutableListOf() + val successCnt = AtomicInteger(0) + val conflictCnt = AtomicInteger(0) + val tasks = (0..9).map { + threadPool.submit { + val responseStatus = mvc.perform( + post("/api/v1/reservation/reserve") + .content( + mapper.writeValueAsString( + mapOf( + "reservationId" to reservationId[it], + ), + ), + ) + .header("Authorization", "Bearer ${accessToken[it]}") + .contentType(MediaType.APPLICATION_JSON) + ).andReturn().response.status + results.add(responseStatus) + if (responseStatus == 200) { successCnt.incrementAndGet() } + if (responseStatus == 409) { conflictCnt.incrementAndGet() } + } + } + tasks.forEach { it.get() } + assert(successCnt.get() == 10) {"expected 10 success but ${successCnt.get()}"} + assert(conflictCnt.get() == 0) {"expected 0 conflict but ${conflictCnt.get()}"} + } } \ No newline at end of file From fbeb96871c8c5c47d8c6ad28b0c37163b666b0b5 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus <153504213+ChungPlusPlus@users.noreply.github.com> Date: Mon, 20 Jan 2025 17:49:25 +0900 Subject: [PATCH 057/162] =?UTF-8?q?=EC=98=88=EB=A7=A4=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=EC=84=B1=20=EC=B2=98=EB=A6=AC,=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * merge test - generate performancehallentity.kt * feat: searchPerformance * chore: 도커 컴포즈 dockerfile 작성해서 도커 이미지 잘 만들어지고 http 파일로 테스트까지 가능한 상태입니다 * modify searchPerformance * add: docker-compose * modify build.gradle.kts * add wildcard import in UserService.kt * change Performance attribute 'genre' to 'category' * change category to ENUM Type * add some Init Datas * add some Init Datas * assign posterimageURL * add: Entity 엔티티 작업중 * assign posterimageURL * add: SeatService 임시저장하겠습니다 * feat: SeatService 좌석 확인, 예매, 예매 취소 기능 구현 아직 동시성 처리 안됐습니다 * feat: Add Controller 컨트롤러 추가했습니다 곧 테스트 코드 추가해보겠습니다 * modify searchPerformance, GET요청은 RequestBody를 사용할 수 없다고 하여 다시 RequestParam을 사용하는 원래 방식으로 변경 * feat: SeatCreation performanceHallId와 type을 받아 좌석엔티티들을 자동으로 생성하는 서비스 함수와 performanceEventId를 받아 빈 예약 가능 엔티티들을 자동을 생성하는 서비스를 만들었습니다 추후 createPerformanceHall과 createPerformanceEvent함수를 수정해서 과정을 더 자동화할 수 있을 것 같습니다 * add: SeatIntegrationTest SeatIntegrationTest 만들었습니다 테스트 전부 통과하며 빌드 성공합니다 다만 테스트에 mysql 데이터베이스가 쓰이며 초기화가 자동으로 되지 않아 문제가 생기니 이는 개선이 필요합니다 * chore: use h2 while test application.yaml 파일을 테스트 코드를 위해 하나 추가해줬습니다 테스트코드에서 @Transactional까지 사용한다면 테스트 코드가 서로 영향을 끼치지 않게 됩니다 * fix: Seat Reservation reservation 바뀐 정보 반영하도록 save 사용했고, Detail확인하는 영역은 컨트롤러까지 분리했습니다 * style: seat ktlint seat폴더의 파일들에 ktlint 적용 * modify MakeFile * 공연 검색 기능 구현 * fromEntity parameter change: receive Entities -> receive DTOs * modify searchPerformance endpoint URL * chore: makefile OS 따라서 다르게 작동하게 수정 * 공연 1개 상세 정보 반환 * minor modify: change how null type is handled * 초기 데이터 수정, 공연이벤트는 일단 공연 기간의 첫날과 마지막날만 생성하였음 * modify DataInitializer, SeatIntegrationTest * add PerformanceDetail Uri * make PerformanceItegrationTest.kt * feat: 예매 동시성 동시성 처리 완료 동시성 처리는 잘 되지만, 테스트 코드 작성이 많이 까다로움 * feat: Get My Reservation Api 유저가 로그인한채로 본인의 예약 목록을 확인할 수 있는 기능 추가 * feat: Seat Get TestCode Get API가 정상작동하는지 테스트코드 추가 * modify SearchPerformanceResponse * check auth for create, delete performance * check auth for create, delete performanceEvent, performanceHall * add posterUri attr to BriefPerformanceDetail * minor change * feat: 예매목록 Brief로 주기 기존에 id만 주던 것을 Brief 데이터의 리스트를 주는 것으로 수정 signin을 할 때 User도 같이 반환하도록 수정 * fix: 로그아웃, 토큰 재발급 버그 수정, 엔드포인트 수정 * add UserIdentityNotFoundException * modify PerformanceDurtion * feat: 공연장 생성시 좌석도 함께 생성 * feat: seat 초기화 적용, 버그 수정 PerformanceHall이 생성될 때 Seat을 같이 생성, PerformanceEvent가 생성될 때 Reservation을 같이 생성하도록 변경 * feat: Find PerformanceEvent PerformanceId와 LocalDate로부터 PerformanceEventId를 반환하도록 추가 잘못된 PerformanceEventId로 빈좌석정보를 확인했을 때 에러 반환 * chore: .env 무시 * feat: Reinforce Simultaneous test code 동시에 여러 사람의 접근이 있어도 통과하는지 테스트 코드 추가 동시에 여러 사람이 서로 다른 좌석에 접근할 때 테스트 코드 추가 --------- Co-authored-by: Dohyeon Kim --- .gitignore | 4 +- .../controller/PerformanceEventController.kt | 17 + .../persistence/PerformanceEventRepository.kt | 13 + .../service/PerformanceEventService.kt | 23 ++ .../service/PerformanceHallService.kt | 3 + .../interpark/seat/controller/Reservation.kt | 17 + .../seat/controller/SeatController.kt | 20 ++ .../seat/persistence/ReservationRepository.kt | 8 + .../interpark/seat/service/SeatService.kt | 31 +- .../interpark/user/UserException.kt | 2 +- .../user/controller/UserController.kt | 33 +- .../interpark/user/service/UserService.kt | 7 +- .../interpark/PerformanceIntegrationTest.kt | 8 +- .../interpark/ReplyIntegrationTest.kt | 8 +- .../interpark/ReviewIntegrationTest.kt | 8 +- .../interpark/SeatIntegrationTest.kt | 135 +++++--- .../interpark/SimultaneousTest.kt | 319 ++++++++++++++++++ .../interpark/UserIntegrationTest.kt | 81 ++++- src/test/resources/SeatApi.http | 36 ++ src/test/resources/UserApi.http | 6 +- 20 files changed, 675 insertions(+), 104 deletions(-) create mode 100644 src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt create mode 100644 src/test/resources/SeatApi.http diff --git a/.gitignore b/.gitignore index b1dff0d..d849736 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,6 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store + +.env \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt index 87e59d4..091ec45 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt @@ -9,6 +9,8 @@ import com.wafflestudio.interpark.user.service.UserService import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* +import java.time.LocalDate +import java.time.format.DateTimeFormatter @RestController class PerformanceEventController( @@ -24,6 +26,21 @@ class PerformanceEventController( .getAllPerformanceEvent() return ResponseEntity.ok(performanceEventList) } + + @GetMapping("/api/v1/performance-event/{performanceId}/{performanceDate}") + fun getPerformanceEventFromDate( + @AuthUser user: User, + @PathVariable performanceId: String, + @PathVariable performanceDate: String, + ): ResponseEntity { + val localPerformanceDate = LocalDate.parse(performanceDate) + val performanceEventList = performanceEventService.getPerformanceEventFromDate( + performanceId = performanceId, + performanceDate = localPerformanceDate, + ) + + return ResponseEntity.ok(performanceEventList) + } // WARN: THIS IS FOR ADMIN. // TODO: SEPERATE THIS TO OTHER APPLICATION diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventRepository.kt index 7adad1f..5288023 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventRepository.kt @@ -1,7 +1,20 @@ package com.wafflestudio.interpark.performance.persistence import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import java.time.Instant interface PerformanceEventRepository : JpaRepository { fun findAllByPerformanceId(performanceId: String): List + + @Query( + """ + SELECT e + FROM PerformanceEventEntity e + WHERE e.performance.id = :performanceId + AND e.startAt >= :startTime + AND e.startAt < :endTime + """ + ) + fun findByPerformanceIdAndDate(performanceId: String, startTime: Instant, endTime: Instant): List } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt index 4416584..75c1ab4 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt @@ -3,11 +3,15 @@ package com.wafflestudio.interpark.performance.service import com.wafflestudio.interpark.performance.* import com.wafflestudio.interpark.performance.controller.* import com.wafflestudio.interpark.performance.persistence.* +import com.wafflestudio.interpark.seat.persistence.ReservationRepository +import com.wafflestudio.interpark.seat.persistence.SeatRepository +import com.wafflestudio.interpark.seat.service.SeatCreationService import org.springframework.data.jpa.domain.Specification import org.springframework.stereotype.Service import org.springframework.data.repository.findByIdOrNull import java.time.Instant +import java.time.LocalDate import java.time.LocalDateTime import java.time.ZoneId @@ -16,6 +20,7 @@ class PerformanceEventService( private val performanceRepository: PerformanceRepository, private val performanceHallRepository: PerformanceHallRepository, private val performanceEventRepository: PerformanceEventRepository, + private val seatCreationService: SeatCreationService ) { fun getAllPerformanceEvent(): List { return performanceEventRepository @@ -23,6 +28,21 @@ class PerformanceEventService( .map { PerformanceEvent.fromEntity(it) }; } + fun getPerformanceEventFromDate( + performanceId: String, + performanceDate: LocalDate, + ): List { + val startOfDate = performanceDate.atStartOfDay().atZone(ZoneId.of("Asia/Seoul")).toInstant() + val endOfDate = performanceDate.plusDays(1).atStartOfDay().atZone(ZoneId.of("Asia/Seoul")).toInstant() + return performanceEventRepository + .findByPerformanceIdAndDate( + performanceId = performanceId, + startTime = startOfDate, + endTime = endOfDate, + ) + .map { PerformanceEvent.fromEntity(it) }; + } + fun parseKoreanTimeToInstant(koreanTime: String): Instant { val koreanZone = ZoneId.of("Asia/Seoul") return LocalDateTime.parse(koreanTime).atZone(koreanZone).toInstant() @@ -47,6 +67,9 @@ class PerformanceEventService( ).let{ performanceEventRepository.save(it) } + + seatCreationService.createEmptyReservations(newPerformanceEventEntity.id) + return PerformanceEvent.fromEntity(newPerformanceEventEntity) } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceHallService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceHallService.kt index 7e95d25..b87ba99 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceHallService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceHallService.kt @@ -3,6 +3,7 @@ package com.wafflestudio.interpark.performance.service import com.wafflestudio.interpark.performance.* import com.wafflestudio.interpark.performance.controller.* import com.wafflestudio.interpark.performance.persistence.* +import com.wafflestudio.interpark.seat.service.SeatCreationService import org.springframework.data.jpa.domain.Specification import org.springframework.stereotype.Service import org.springframework.data.repository.findByIdOrNull @@ -10,6 +11,7 @@ import org.springframework.data.repository.findByIdOrNull @Service class PerformanceHallService( private val performanceHallRepository: PerformanceHallRepository, + private val seatCreationService: SeatCreationService ) { fun getAllPerformanceHall(): List { return performanceHallRepository @@ -34,6 +36,7 @@ class PerformanceHallService( ).let{ performanceHallRepository.save(it) } + seatCreationService.createSeats(newPerformanceHallEntity.id!!, "DEFAULT") return PerformanceHall.fromEntity(newPerformanceHallEntity) } diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt index a6d2e81..bc80b44 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/Reservation.kt @@ -7,10 +7,12 @@ import com.wafflestudio.interpark.seat.persistence.ReservationEntity import com.wafflestudio.interpark.seat.persistence.SeatEntity import java.time.Instant import java.time.LocalDate +import java.time.ZoneId data class Reservation( val id: String, val performanceTitle: String, + val posterUri: String, val performanceHallName: String, val seat: Seat, val performanceStartAt: Instant, @@ -28,6 +30,7 @@ data class Reservation( return Reservation( id = reservationEntity.id!!, performanceTitle = performanceEntity.title, + posterUri = performanceEntity.posterUri, performanceHallName = performanceHallEntity.name, seat = Seat.fromEntity(seatEntity), performanceStartAt = performanceEventEntity.startAt, @@ -35,5 +38,19 @@ data class Reservation( reservationDate = reservationEntity.reservationDate!!, ) } + + fun fromEntityToBriefDetails( + reservationEntity: ReservationEntity, + performanceEntity: PerformanceEntity, + performanceEventEntity: PerformanceEventEntity, + ): BriefReservation { + return BriefReservation( + id = reservationEntity.id!!, + performanceTitle = performanceEntity.title, + posterUri = performanceEntity.posterUri, + performanceDate = performanceEventEntity.startAt.atZone(ZoneId.of("Asia/Seoul")).toLocalDate(), + reservationDate = reservationEntity.reservationDate!!, + ) + } } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt index 0f1d8d0..7968bda 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt @@ -9,6 +9,7 @@ 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.RestController +import java.time.LocalDate @RestController class SeatController( @@ -31,6 +32,14 @@ class SeatController( return ResponseEntity.status(200).body(ReserveSeatResponse(reservationId)) } + @GetMapping("/api/v1/me/reservation") + fun getMyReservations( + @AuthUser user: User, + ): ResponseEntity { + val myReservations = seatService.getMyReservations(user) + return ResponseEntity.ok(GetMyReservationsResponse(myReservations)) + } + @GetMapping("/api/v1/reservation/detail/{reservationId}") fun getReservedSeatDetail( @PathVariable reservationId: String, @@ -50,6 +59,13 @@ class SeatController( } } +data class BriefReservation( + val id: String, + val performanceTitle: String, + val posterUri: String, + val performanceDate: LocalDate, + val reservationDate: LocalDate, +) data class AvailableSeat( val reservationId: String, val seat: Seat, @@ -67,6 +83,10 @@ data class ReserveSeatResponse( val reservationId: String, ) +data class GetMyReservationsResponse( + val myReservations: List, +) + data class GetReservedSeatDetailResponse( val reservedSeat: Reservation ) diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt index 5912ebf..826d118 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt @@ -1,9 +1,17 @@ package com.wafflestudio.interpark.seat.persistence +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 interface ReservationRepository : JpaRepository { + @Query("SELECT r FROM ReservationEntity r WHERE r.user.id = :userId ORDER BY r.reservationDate DESC") fun findByUserId(userId: String): List fun findByPerformanceEventIdAndReservedIsFalse(performanceEventId: String): List + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM ReservationEntity r WHERE r.id = :id") + fun findByIdWithWriteLock(id: String): ReservationEntity? } diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt index fe896dd..1f1a912 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt @@ -1,8 +1,12 @@ package com.wafflestudio.interpark.seat.service +import com.wafflestudio.interpark.performance.PerformanceEventNotFoundException +import com.wafflestudio.interpark.performance.persistence.PerformanceEventRepository import com.wafflestudio.interpark.seat.ReservationNotFoundException +import com.wafflestudio.interpark.seat.ReservationPermissionDeniedException import com.wafflestudio.interpark.seat.ReservedAlreadyException import com.wafflestudio.interpark.seat.ReservedYetException +import com.wafflestudio.interpark.seat.controller.BriefReservation import com.wafflestudio.interpark.seat.controller.Reservation import com.wafflestudio.interpark.seat.controller.Seat import com.wafflestudio.interpark.seat.persistence.ReservationRepository @@ -18,11 +22,12 @@ import java.time.LocalDate @Service class SeatService( private val reservationRepository: ReservationRepository, - private val seatRepository: SeatRepository, + private val performanceEventRepository: PerformanceEventRepository, private val userRepository: UserRepository, ) { @Transactional fun getAvailableSeats(performanceEventId: String): List> { + performanceEventRepository.findByIdOrNull(performanceEventId) ?: throw PerformanceEventNotFoundException() val availableReservations = reservationRepository.findByPerformanceEventIdAndReservedIsFalse(performanceEventId) val availableSeats = availableReservations.map { Pair(it.id!!, Seat.fromEntity(it.seat)) } return availableSeats @@ -33,9 +38,8 @@ class SeatService( user: User, reservationId: String, ): String { - // TODO: 동시성 처리하기 val targetUser = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() - val targetReservation = reservationRepository.findByIdOrNull(reservationId) ?: throw ReservationNotFoundException() + val targetReservation = reservationRepository.findByIdWithWriteLock(reservationId) ?: throw ReservationNotFoundException() if (targetReservation.reserved) throw ReservedAlreadyException() @@ -47,6 +51,23 @@ class SeatService( return reservationId } + @Transactional + fun getMyReservations(user: User): List { + userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() + val myReservations = reservationRepository.findByUserId(user.id) + + return myReservations.map { reservationEntity -> + val performanceEventEntity = reservationEntity.performanceEvent + val performanceEntity = performanceEventEntity.performance + + Reservation.fromEntityToBriefDetails( + reservationEntity = reservationEntity, + performanceEntity = performanceEntity, + performanceEventEntity = performanceEventEntity + ) + } + } + @Transactional fun getReservedSeatDetail( user: User, @@ -56,7 +77,7 @@ class SeatService( val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() val reservationUser = reservationEntity.user ?: throw ReservedYetException() if (reservationUser.id != userEntity.id) { - throw AuthenticateException() + throw ReservationPermissionDeniedException() } val seatEntity = reservationEntity.seat @@ -84,7 +105,7 @@ class SeatService( val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() val reservationUser = reservationEntity.user ?: throw ReservedYetException() if (reservationUser.id != userEntity.id) { - throw AuthenticateException() + throw ReservationPermissionDeniedException() } reservationEntity.user = null diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt b/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt index f9ab9b1..21c19fe 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt @@ -59,7 +59,7 @@ class TokenExpiredException : UserException( msg = "Token Expired", ) -class TokenNotFoundException : UserException( +class NoRefreshTokenException : UserException( errorCode = 0, httpStatusCode = HttpStatus.UNAUTHORIZED, msg = "Token not found", diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt index a388b1d..45687cd 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt @@ -25,7 +25,7 @@ class UserController( return ResponseEntity.ok(mapOf("message" to "pong")) } - @PostMapping("/api/v1/signup") + @PostMapping("/api/v1/local/signup") @Operation( summary = "사용자 회원가입", description = """ @@ -99,23 +99,23 @@ class UserController( return ResponseEntity.ok(SignUpResponse(user)) } - @PostMapping("/api/v1/signin") + @PostMapping("/api/v1/local/signin") fun signin( @RequestBody request: SignInRequest, response: HttpServletResponse, - ): ResponseEntity { - val (accessToken, refreshToken) = userService.signIn(request.username, request.password) + ): ResponseEntity { + val (user, accessToken, refreshToken) = userService.signIn(request.username, request.password) val cookie = Cookie("refreshToken", refreshToken).apply { isHttpOnly = true secure = true - path = "/api/v1/refresh_token" + path = "/api/v1/auth" maxAge = 60 * 60 * 24 * 7 // TODO("domain 설정하기") } response.addCookie(cookie) - return ResponseEntity.ok(TokenResponse(accessToken)) + return ResponseEntity.ok(SignInResponse(user, accessToken)) } @GetMapping("/api/v1/users/me") @@ -125,24 +125,24 @@ class UserController( return ResponseEntity.ok(user) } - @PostMapping("/api/v1/signout") + @PostMapping("/api/v1/auth/signout") fun signout( - @CookieValue(value = "refresh_token", required = false) refreshToken: String?, + @CookieValue(value = "refreshToken", required = false) refreshToken: String?, ): ResponseEntity { if (refreshToken == null) { - throw TokenNotFoundException() + throw NoRefreshTokenException() } userService.signOut(refreshToken) return ResponseEntity.noContent().build() } - @PostMapping("/api/v1/refresh_token") + @PostMapping("/api/v1/auth/refresh_token") fun refreshToken( @CookieValue(value = "refreshToken", required = false) refreshToken: String?, response: HttpServletResponse, ): ResponseEntity { if (refreshToken == null) { - throw TokenNotFoundException() + throw NoRefreshTokenException() } val (newAccessToken, newRefreshToken) = userService.refreshAccessToken(refreshToken) @@ -151,7 +151,7 @@ class UserController( Cookie("refreshToken", newRefreshToken).apply { isHttpOnly = true secure = true - path = "/api/v1/refresh_token" + path = "/api/v1/auth" maxAge = 60 * 60 * 24 * 7 // TODO("domain 설정하기") } @@ -177,10 +177,11 @@ data class SignInRequest( val password: String, ) -data class TokenResponse( +data class SignInResponse( + val user: User, val accessToken: String, ) -data class SignOutRequest(val refreshToken: String) - -data class RefreshTokenRequest(val refreshToken: String) +data class TokenResponse( + val accessToken: String, +) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt index fd774c5..521f61b 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt @@ -61,15 +61,16 @@ class UserService( fun signIn( username: String, password: String, - ): Pair { + ): Triple { val targetUser = userRepository.findByUsername(username) ?: throw SignInUserNotFoundException() + val user = User.fromEntity(targetUser) val targetIdentity = userIdentityRepository.findByUser(targetUser) ?: throw SignInUserNotFoundException() if (!BCrypt.checkpw(password, targetIdentity.hashedPassword)) { throw SignInInvalidPasswordException() } val accessToken = userAccessTokenUtil.generateAccessToken(targetUser.id!!) - val refreshToken = userAccessTokenUtil.generateRefreshToken(targetIdentity.id!!) - return Pair(accessToken, refreshToken) + val refreshToken = userAccessTokenUtil.generateRefreshToken(targetUser.id!!) + return Triple(user, accessToken, refreshToken) } @Transactional diff --git a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt index ef8de17..76e6b3e 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt @@ -37,7 +37,7 @@ constructor( // 1️⃣ 회원가입 // 일반 유저 mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -54,7 +54,7 @@ constructor( // 관리자 mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -74,7 +74,7 @@ constructor( // 일반 유저 userAccessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( @@ -93,7 +93,7 @@ constructor( // 관리자 adminAccessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt index 92ad67d..8691045 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt @@ -34,7 +34,7 @@ class ReplyIntegrationTest // 1️⃣ 회원가입 mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -52,7 +52,7 @@ class ReplyIntegrationTest // 2️⃣ 로그인 → 토큰 획득 accessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( @@ -199,7 +199,7 @@ class ReplyIntegrationTest // 다른 사용자 생성 mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -217,7 +217,7 @@ class ReplyIntegrationTest // 다른 사용자 로그인 val otherAccessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt index 7c86a32..391de23 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt @@ -34,7 +34,7 @@ class ReviewIntegrationTest // 1️⃣ 회원가입 mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -52,7 +52,7 @@ class ReviewIntegrationTest // 2️⃣ 로그인 → 토큰 획득 accessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( @@ -178,7 +178,7 @@ class ReviewIntegrationTest fun `리뷰 삭제 실패 - 권한 없음`() { // 다른 사용자 생성 mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -196,7 +196,7 @@ class ReviewIntegrationTest // 다른 사용자로 로그인 val otherAccessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( diff --git a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt index 283f1de..d4dfcb8 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt @@ -27,8 +27,6 @@ constructor( private val seatCreationService: SeatCreationService, ) { private lateinit var accessToken: String - private lateinit var performanceHallId: String - private lateinit var performanceId: String private lateinit var performanceEventId: String @BeforeEach fun setup() { @@ -37,7 +35,7 @@ constructor( // 1️⃣ 회원가입 mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -55,7 +53,7 @@ constructor( // 2️⃣ 로그인 → 토큰 획득 accessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( @@ -71,46 +69,6 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it).get("accessToken").asText() } - //Seat와 Reservation 만들기 위한 EventId 만들기 - performanceHallId = - mvc.perform( - get("/api/v1/performance-hall") - .header("Authorization", "Bearer $accessToken"), - ).andExpect(status().`is`(200)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { - val performanceHalls = mapper.readTree(it) - performanceHalls[0].get("id").asText() - } - performanceId = - mvc.perform( - get("/api/v1/performance/search") - ).andExpect(status().`is`(200)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { - val performances = mapper.readTree(it) - performances[0].get("id").asText() - } - - mvc.perform( - post("/admin/v1/performance-event") - .content( - mapper.writeValueAsString( - mapOf( - "performanceId" to performanceId, - "performanceHallId" to performanceHallId, - "startAt" to LocalDateTime.now(ZoneId.of("Asia/Seoul")), - "endAt" to LocalDateTime.now(ZoneId.of("Asia/Seoul")) - ), - ), - ) - .contentType(MediaType.APPLICATION_JSON) - ) - performanceEventId = mvc.perform( get("/api/v1/performance-event") @@ -123,20 +81,23 @@ constructor( val performanceEvents = mapper.readTree(it) performanceEvents[0].get("id").asText() } - //Seat와 Reservation만들기 - seatCreationService.createSeats(performanceHallId, "DEFAULT") - seatCreationService.createEmptyReservations(performanceEventId) } @Test fun `가능한 좌석들의 정보를 받을 수 있다`() { - mvc.perform( + val availableSeats = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("availableSeats")} + + assert(availableSeats.size()==100) {"expected Seats are 100 but found ${availableSeats.size()}"} } @Test - fun `좌석을 예매할 수 있다`() { + fun `좌석을 예매할 수 있고 예매되면 더 이상 예매되지 못한다`() { val reservationId = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) @@ -174,6 +135,76 @@ constructor( ).andExpect(status().`is`(409)) } + @Test + fun `본인의 예매내역을 확인할 수 있다`() { + val reservationId = mvc.perform( + get("/api/v1/seat/$performanceEventId/available") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val availableSeats = mapper.readTree(it).get("availableSeats") + availableSeats[0].get("reservationId").asText() + } + mvc.perform( + post("/api/v1/reservation/reserve") + .content( + mapper.writeValueAsString( + mapOf( + "reservationId" to reservationId, + ), + ), + ) + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + ).andExpect(status().`is`(200)) + + val myReservations = mvc.perform( + get("/api/v1/me/reservation") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + mapper.readTree(it).get("myReservations") + } + assert(myReservations.size() == 1) {"Expected size 1 but ${myReservations.size()}"} + assert(myReservations[0].get("id").asText() == reservationId) {"Expected $reservationId but ${myReservations[0].get("id").asText()}"} + } + + @Test + fun `본인의 예매를 자세히 볼 수 있다`() { + val reservationId = mvc.perform( + get("/api/v1/seat/$performanceEventId/available") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val availableSeats = mapper.readTree(it).get("availableSeats") + availableSeats[0].get("reservationId").asText() + } + mvc.perform( + post("/api/v1/reservation/reserve") + .content( + mapper.writeValueAsString( + mapOf( + "reservationId" to reservationId, + ), + ), + ) + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + ).andExpect(status().`is`(200)) + + val myReservationIds = mvc.perform( + get("/api/v1/reservation/detail/$reservationId") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(200)) + } + @Test fun `좌석을 취소할 수 있다`() { val reservationId = mvc.perform( @@ -241,7 +272,7 @@ constructor( } mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -258,7 +289,7 @@ constructor( .andExpect(status().`is`(200)) val otherAccessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( @@ -298,6 +329,6 @@ constructor( ) .header("Authorization", "Bearer $otherAccessToken") .contentType(MediaType.APPLICATION_JSON) - ).andExpect(status().`is`(401)) + ).andExpect(status().`is`(403)) } } \ No newline at end of file diff --git a/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt new file mode 100644 index 0000000..445c67a --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt @@ -0,0 +1,319 @@ +package com.wafflestudio.interpark + +import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.interpark.seat.service.SeatCreationService +import com.wafflestudio.interpark.user.persistence.UserRepository +import com.wafflestudio.interpark.user.service.UserService +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import java.time.Instant +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger + +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class SimultaneousTest +@Autowired +constructor( + private val mvc: MockMvc, + private val mapper: ObjectMapper, + private val seatCreationService: SeatCreationService, + private val userRepository: UserRepository, +) { + @Test + fun `한 예매에 동시에 여러 접근이 있다면 하나만 통과시킨다`() { + val threadPool = Executors.newFixedThreadPool(10) + val username = UUID.randomUUID().toString().take(8) + val password = "password123" + + // 1️⃣ 회원가입 + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + "nickname" to "reviewer", + "phoneNumber" to "010-0000-0000", + "email" to "reviewer@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + // 2️⃣ 로그인 → 토큰 획득 + val accessToken = + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + val performanceEventId = + mvc.perform( + get("/api/v1/performance-event") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performanceEvents = mapper.readTree(it) + performanceEvents[0].get("id").asText() + } + + val reservationId = mvc.perform( + get("/api/v1/seat/$performanceEventId/available") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val availableSeats = mapper.readTree(it).get("availableSeats") + availableSeats[0].get("reservationId").asText() + } + + val results = mutableListOf() + val successCnt = AtomicInteger(0) + val conflictCnt = AtomicInteger(0) + val tasks = (1..10).map { + threadPool.submit { + val responseStatus = mvc.perform( + post("/api/v1/reservation/reserve") + .content( + mapper.writeValueAsString( + mapOf( + "reservationId" to reservationId, + ), + ), + ) + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + ).andReturn().response.status + results.add(responseStatus) + if (responseStatus == 200) { successCnt.incrementAndGet() } + if (responseStatus == 409) { conflictCnt.incrementAndGet() } + } + } + tasks.forEach { it.get() } + assert(successCnt.get() == 1) {"expected 1 success but ${successCnt.get()}"} + assert(conflictCnt.get() == 9) {"expected 9 conflict but ${conflictCnt.get()}"} + } + + @Test + fun `한 예매에 동시에 여러명이 접속해도 하나만 통과한다`() { + val threadPool = Executors.newFixedThreadPool(10) + val username = (0..9).map { UUID.randomUUID().toString().take(16) } + val password = "password123" + + // 1️⃣ 회원가입 + (0..9).map { + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username[it], + "password" to password, + "nickname" to "reviewer", + "phoneNumber" to "010-0000-0000", + "email" to "reviewer@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + } + + // 2️⃣ 로그인 → 토큰 획득 + val accessToken = + (0..9).map { + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username[it], + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + } + + val performanceEventId = + mvc.perform( + get("/api/v1/performance-event") + .header("Authorization", "Bearer ${accessToken[0]}"), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performanceEvents = mapper.readTree(it) + performanceEvents[0].get("id").asText() + } + + val reservationId = mvc.perform( + get("/api/v1/seat/$performanceEventId/available") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val availableSeats = mapper.readTree(it).get("availableSeats") + availableSeats[0].get("reservationId").asText() + } + + val results = mutableListOf() + val successCnt = AtomicInteger(0) + val conflictCnt = AtomicInteger(0) + val tasks = (0..9).map { + threadPool.submit { + val responseStatus = mvc.perform( + post("/api/v1/reservation/reserve") + .content( + mapper.writeValueAsString( + mapOf( + "reservationId" to reservationId, + ), + ), + ) + .header("Authorization", "Bearer ${accessToken[it]}") + .contentType(MediaType.APPLICATION_JSON) + ).andReturn().response.status + results.add(responseStatus) + if (responseStatus == 200) { successCnt.incrementAndGet() } + if (responseStatus == 409) { conflictCnt.incrementAndGet() } + } + } + tasks.forEach { it.get() } + assert(successCnt.get() == 1) {"expected 1 success but ${successCnt.get()}"} + assert(conflictCnt.get() == 9) {"expected 9 conflict but ${conflictCnt.get()}"} + } + + @Test + fun `동시에 다른 좌석에 접근하면 모두 잘 처리된다`() { + val threadPool = Executors.newFixedThreadPool(10) + val username = (0..9).map { UUID.randomUUID().toString().take(15) } + val password = "password123" + + // 1️⃣ 회원가입 + (0..9).map { + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username[it], + "password" to password, + "nickname" to "reviewer", + "phoneNumber" to "010-0000-0000", + "email" to "reviewer@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + } + + // 2️⃣ 로그인 → 토큰 획득 + val accessToken = + (0..9).map { + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username[it], + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + } + + val performanceEventId = + mvc.perform( + get("/api/v1/performance-event") + .header("Authorization", "Bearer ${accessToken[0]}"), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performanceEvents = mapper.readTree(it) + performanceEvents[0].get("id").asText() + } + + val reservationId = mvc.perform( + get("/api/v1/seat/$performanceEventId/available") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val availableSeats = mapper.readTree(it).get("availableSeats") + (1..10).map { availableSeats[it].get("reservationId").asText() } + } + + val results = mutableListOf() + val successCnt = AtomicInteger(0) + val conflictCnt = AtomicInteger(0) + val tasks = (0..9).map { + threadPool.submit { + val responseStatus = mvc.perform( + post("/api/v1/reservation/reserve") + .content( + mapper.writeValueAsString( + mapOf( + "reservationId" to reservationId[it], + ), + ), + ) + .header("Authorization", "Bearer ${accessToken[it]}") + .contentType(MediaType.APPLICATION_JSON) + ).andReturn().response.status + results.add(responseStatus) + if (responseStatus == 200) { successCnt.incrementAndGet() } + if (responseStatus == 409) { conflictCnt.incrementAndGet() } + } + } + tasks.forEach { it.get() } + assert(successCnt.get() == 10) {"expected 10 success but ${successCnt.get()}"} + assert(conflictCnt.get() == 0) {"expected 0 conflict but ${conflictCnt.get()}"} + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt index d1ded75..defc230 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt @@ -1,10 +1,15 @@ package com.wafflestudio.interpark import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.interpark.user.UserAccessTokenUtil +import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.persistence.UserRepository +import jakarta.servlet.http.Cookie import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest +import org.springframework.data.repository.findByIdOrNull import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get @@ -21,12 +26,14 @@ class UserIntegrationTest constructor( private val mvc: MockMvc, private val mapper: ObjectMapper, + private val userAccessTokenUtil: UserAccessTokenUtil, + private val userRepository: UserRepository, ) { @Test fun `회원가입시에 유저 이름 혹은 비밀번호가 정해진 규칙에 맞지 않는 경우 400 응답을 내려준다`() { // bad username mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -44,7 +51,7 @@ class UserIntegrationTest // bad password mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -61,7 +68,7 @@ class UserIntegrationTest .andExpect(status().`is`(400)) mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -81,7 +88,7 @@ class UserIntegrationTest @Test fun `회원가입시에 이미 해당 유저 이름이 존재하면 409 응답을 내려준다`() { mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -98,7 +105,7 @@ class UserIntegrationTest .andExpect(status().`is`(200)) mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -118,7 +125,7 @@ class UserIntegrationTest @Test fun `로그인 정보가 정확하지 않으면 401 응답을 내려준다`() { mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -136,7 +143,7 @@ class UserIntegrationTest // not exist username mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( @@ -151,7 +158,7 @@ class UserIntegrationTest // wrong password mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( @@ -165,7 +172,7 @@ class UserIntegrationTest .andExpect(status().`is`(401)) mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( @@ -179,11 +186,63 @@ class UserIntegrationTest .andExpect(status().`is`(200)) } + @Test + fun `토큰 재발행이 가능하다`() { + val (username, password) = "correct5" to "12345678" + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + "nickname" to username, + "phoneNumber" to "010-0000-0000", + "email" to "test@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ) + .andExpect(status().`is`(200)) + + val refreshToken = mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andReturn().response.cookies.find { it.name == "refreshToken" }!!.value + + val newAccessToken = + mvc.perform( + post("/api/v1/auth/refresh_token") + .cookie(Cookie("refreshToken", refreshToken)) + ) + .andExpect(status().`is`(200)) + .andReturn() + .response.getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + mvc.perform( + get("/api/v1/users/me") + .header("Authorization", "Bearer $newAccessToken"), + ) + .andExpect(status().`is`(200)) + .andExpect(jsonPath("$.username").value(username)) + .andExpect(jsonPath("$.nickname").value(username)) + } + @Test fun `잘못된 인증 토큰으로 인증시 401 응답을 내려준다`() { val (username, password) = "correct4" to "12345678" mvc.perform( - post("/api/v1/signup") + post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( @@ -201,7 +260,7 @@ class UserIntegrationTest val accessToken = mvc.perform( - post("/api/v1/signin") + post("/api/v1/local/signin") .content( mapper.writeValueAsString( mapOf( diff --git a/src/test/resources/SeatApi.http b/src/test/resources/SeatApi.http new file mode 100644 index 0000000..c232081 --- /dev/null +++ b/src/test/resources/SeatApi.http @@ -0,0 +1,36 @@ +### 회원가입 +POST http://localhost:8080/api/v1/local/signup +Content-Type: application/json + +{ + "username": "good_name", + "password": "12345678", + "nickname": "examplename", + "phoneNumber": "010-0000-0000", + "email": "test@example.com" +} + +### 로그인 +POST http://localhost:8080/api/v1/local/signin +Content-Type: application/json + +{ + "username": "good_name", + "password": "12345678" +} + +### performance 받기 +GET http://localhost:8080/api/v1/performance/search +Accept: application/json + +### event 찾기 +GET http://localhost:8080/api/v1/performance-event/e1656490-a5be-446e-b119-d8efa393dd05/2025-01-11 +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkODUyNjcwMy0zM2Y2LTRmM2ItYTdhMC1mOGZlOGQ3NTcxNDAiLCJpYXQiOjE3MzcyNzcyMjQsImV4cCI6MTczNzI3ODEyNH0.KObDWrRBDRZU-iNyFHHherkbxIjigmjpVn-Iv5Lx3yY + +### event 받기 +GET http://localhost:8080/api/v1/performance-event +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkODUyNjcwMy0zM2Y2LTRmM2ItYTdhMC1mOGZlOGQ3NTcxNDAiLCJpYXQiOjE3MzcyNzcyMjQsImV4cCI6MTczNzI3ODEyNH0.KObDWrRBDRZU-iNyFHHherkbxIjigmjpVn-Iv5Lx3yY + +### 가능한 좌석 받기 +GET http://localhost:8080/api/v1/seat/eade9900-a141-4532-bd2c-c362ceb7c8c0/available +Accept: application/json \ No newline at end of file diff --git a/src/test/resources/UserApi.http b/src/test/resources/UserApi.http index e12f293..6811bdf 100644 --- a/src/test/resources/UserApi.http +++ b/src/test/resources/UserApi.http @@ -1,5 +1,5 @@ ### 회원가입 -POST http://localhost:8080/api/v1/signup +POST http://localhost:8080/api/v1/local/signup Content-Type: application/json { @@ -11,7 +11,7 @@ Content-Type: application/json } ### 로그인 -POST http://localhost:8080/api/v1/signin +POST http://localhost:8080/api/v1/local/signin Content-Type: application/json { @@ -20,7 +20,7 @@ Content-Type: application/json } ### 로그아웃 -POST http://localhost:8080/api/v1/signout +POST http://localhost:8080/api/v1/auth/signout Content-Type: application/json ### pingpong From b23f74c2720388896557fd83757cce51cfd670cf Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Mon, 20 Jan 2025 22:30:34 +0900 Subject: [PATCH 058/162] fix .http localhost port number --- src/test/resources/SeatApi.http | 12 ++++++------ src/test/resources/UserApi.http | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/test/resources/SeatApi.http b/src/test/resources/SeatApi.http index c232081..5939758 100644 --- a/src/test/resources/SeatApi.http +++ b/src/test/resources/SeatApi.http @@ -1,5 +1,5 @@ ### 회원가입 -POST http://localhost:8080/api/v1/local/signup +POST http://localhost:80/api/v1/local/signup Content-Type: application/json { @@ -11,7 +11,7 @@ Content-Type: application/json } ### 로그인 -POST http://localhost:8080/api/v1/local/signin +POST http://localhost:80/api/v1/local/signin Content-Type: application/json { @@ -20,17 +20,17 @@ Content-Type: application/json } ### performance 받기 -GET http://localhost:8080/api/v1/performance/search +GET http://localhost:80/api/v1/performance/search Accept: application/json ### event 찾기 -GET http://localhost:8080/api/v1/performance-event/e1656490-a5be-446e-b119-d8efa393dd05/2025-01-11 +GET http://localhost:80/api/v1/performance-event/e1656490-a5be-446e-b119-d8efa393dd05/2025-01-11 Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkODUyNjcwMy0zM2Y2LTRmM2ItYTdhMC1mOGZlOGQ3NTcxNDAiLCJpYXQiOjE3MzcyNzcyMjQsImV4cCI6MTczNzI3ODEyNH0.KObDWrRBDRZU-iNyFHHherkbxIjigmjpVn-Iv5Lx3yY ### event 받기 -GET http://localhost:8080/api/v1/performance-event +GET http://localhost:80/api/v1/performance-event Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkODUyNjcwMy0zM2Y2LTRmM2ItYTdhMC1mOGZlOGQ3NTcxNDAiLCJpYXQiOjE3MzcyNzcyMjQsImV4cCI6MTczNzI3ODEyNH0.KObDWrRBDRZU-iNyFHHherkbxIjigmjpVn-Iv5Lx3yY ### 가능한 좌석 받기 -GET http://localhost:8080/api/v1/seat/eade9900-a141-4532-bd2c-c362ceb7c8c0/available +GET http://localhost:80/api/v1/seat/eade9900-a141-4532-bd2c-c362ceb7c8c0/available Accept: application/json \ No newline at end of file diff --git a/src/test/resources/UserApi.http b/src/test/resources/UserApi.http index 6811bdf..4d908bd 100644 --- a/src/test/resources/UserApi.http +++ b/src/test/resources/UserApi.http @@ -1,5 +1,5 @@ ### 회원가입 -POST http://localhost:8080/api/v1/local/signup +POST http://localhost:80/api/v1/local/signup Content-Type: application/json { @@ -11,7 +11,7 @@ Content-Type: application/json } ### 로그인 -POST http://localhost:8080/api/v1/local/signin +POST http://localhost:80/api/v1/local/signin Content-Type: application/json { @@ -20,13 +20,13 @@ Content-Type: application/json } ### 로그아웃 -POST http://localhost:8080/api/v1/auth/signout +POST http://localhost:80/api/v1/auth/signout Content-Type: application/json ### pingpong -GET http://localhost:8080/api/v1/ping +GET http://localhost:80/api/v1/ping Accept: application/json ### 인증 토큰으로 유저 프로필 조회 -GET http://localhost:8080/api/v1/users/me +GET http://localhost:80/api/v1/users/me Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJiY2JlMjI5Yi0zYjgyLTRkM2MtOWFjNS03ZWYzZTUyMjViNWYiLCJpYXQiOjE3MzYzMjA2MDksImV4cCI6MTczNjMyMTUwOX0.RJHC9qQvBnt_q4aS81qdpTrTnXfS-qQ-wMygRFXXg8E \ No newline at end of file From 1d15af5969b202f21081470d710a3eb493bdb499 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Tue, 21 Jan 2025 04:32:52 +0900 Subject: [PATCH 059/162] apply spring security --- .../config/CustomAuthenticationEntryPoint.kt | 24 ------------- .../controller/PerformanceController.kt | 22 +++--------- .../controller/PerformanceEventController.kt | 34 ++++--------------- .../controller/PerformanceHallController.kt | 29 +++------------- .../review/controller/ReplyController.kt | 20 ++++++----- .../review/controller/ReviewController.kt | 24 +++++++------ .../interpark/review/service/ReplyService.kt | 21 +++++------- .../interpark/review/service/ReviewService.kt | 17 +++++----- .../seat/controller/SeatController.kt | 18 +++++----- .../interpark/seat/service/SeatService.kt | 19 +++++------ .../JwtAuthenticationFilter.kt | 32 ++++++++--------- .../security/RestAccessDeniedHandler.kt | 29 ++++++++++++++++ .../{config => security}/SecurityConfig.kt | 21 ++++++++---- .../user/controller/UserController.kt | 13 +++++-- .../user/controller/UserDetailsImpl.kt | 6 ++-- .../user/service/UserDetailsServiceImpl.kt | 17 ++++++++-- .../interpark/user/service/UserService.kt | 1 + .../interpark/UserIntegrationTest.kt | 2 +- 18 files changed, 164 insertions(+), 185 deletions(-) delete mode 100644 src/main/kotlin/com/wafflestudio/interpark/config/CustomAuthenticationEntryPoint.kt rename src/main/kotlin/com/wafflestudio/interpark/{user => security}/JwtAuthenticationFilter.kt (60%) create mode 100644 src/main/kotlin/com/wafflestudio/interpark/security/RestAccessDeniedHandler.kt rename src/main/kotlin/com/wafflestudio/interpark/{config => security}/SecurityConfig.kt (57%) diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/CustomAuthenticationEntryPoint.kt b/src/main/kotlin/com/wafflestudio/interpark/config/CustomAuthenticationEntryPoint.kt deleted file mode 100644 index 2da548b..0000000 --- a/src/main/kotlin/com/wafflestudio/interpark/config/CustomAuthenticationEntryPoint.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.wafflestudio.interpark.config - -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.security.core.AuthenticationException -import org.springframework.security.web.AuthenticationEntryPoint -import org.springframework.stereotype.Component - -@Component -class CustomAuthenticationEntryPoint : AuthenticationEntryPoint { - override fun commence( - request: HttpServletRequest, - response: HttpServletResponse, - authException: AuthenticationException - ) { - response.status = HttpServletResponse.SC_UNAUTHORIZED - response.contentType = "application/json" - response.writer.write( - """ - { "error": "Unauthorized", "message": "${authException.message}" } - """.trimIndent() - ) - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index 8ff1a10..e5d6ba8 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -6,6 +6,7 @@ import com.wafflestudio.interpark.performance.service.PerformanceService import com.wafflestudio.interpark.user.AuthUser import com.wafflestudio.interpark.user.UserIdentityNotFoundException import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.controller.UserDetailsImpl import com.wafflestudio.interpark.user.persistence.UserRole import com.wafflestudio.interpark.user.service.UserService import jakarta.validation.Valid @@ -13,6 +14,7 @@ import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* import java.time.LocalDate @@ -39,16 +41,8 @@ class PerformanceController( @PostMapping("/admin/v1/performance") fun createPerformance( @Valid @RequestBody request: CreatePerformanceRequest, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl, ): ResponseEntity { - // UserIdentity를 통해 역할(Role) 확인 - val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 - ?: throw UserIdentityNotFoundException() - - if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) - } - val newPerformance: Performance = performanceService .createPerformance( @@ -76,16 +70,8 @@ class PerformanceController( @DeleteMapping("/admin/v1/performance/{performanceId}") fun deletePerformance( @PathVariable performanceId: String, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl, ): ResponseEntity { - // UserIdentity를 통해 역할(Role) 확인 - val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 - ?: throw UserIdentityNotFoundException() - - if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) - } - performanceService.deletePerformance(performanceId) return ResponseEntity.noContent().build() } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt index 091ec45..306273e 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt @@ -1,25 +1,20 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.service.PerformanceEventService -import com.wafflestudio.interpark.user.controller.User -import com.wafflestudio.interpark.user.AuthUser -import com.wafflestudio.interpark.user.UserIdentityNotFoundException -import com.wafflestudio.interpark.user.persistence.UserRole -import com.wafflestudio.interpark.user.service.UserService +import com.wafflestudio.interpark.user.controller.UserDetailsImpl import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* import java.time.LocalDate -import java.time.format.DateTimeFormatter @RestController class PerformanceEventController( private val performanceEventService: PerformanceEventService, - private val userService: UserService ) { @GetMapping("/api/v1/performance-event") fun getPerformanceEvent( - @AuthUser user: User, + //@AuthUser user: User, ): ResponseEntity { // Currently, no search val performanceEventList: List = performanceEventService @@ -29,7 +24,7 @@ class PerformanceEventController( @GetMapping("/api/v1/performance-event/{performanceId}/{performanceDate}") fun getPerformanceEventFromDate( - @AuthUser user: User, + //@AuthUser user: User, @PathVariable performanceId: String, @PathVariable performanceDate: String, ): ResponseEntity { @@ -38,7 +33,6 @@ class PerformanceEventController( performanceId = performanceId, performanceDate = localPerformanceDate, ) - return ResponseEntity.ok(performanceEventList) } @@ -47,16 +41,8 @@ class PerformanceEventController( @PostMapping("/admin/v1/performance-event") fun createPerformanceEvent( @RequestBody request: CreatePerformanceEventRequest, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl, ): ResponseEntity { - // UserIdentity를 통해 역할(Role) 확인 - val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 - ?: throw UserIdentityNotFoundException() - - if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) - } - val newPerformanceEvent: PerformanceEvent = performanceEventService .createPerformanceEvent( @@ -71,16 +57,8 @@ class PerformanceEventController( @DeleteMapping("/admin/v1/performance-event/{performanceEventId}") fun deletePerformanceEvent( @PathVariable performanceEventId: String, - @AuthUser user: User + @AuthenticationPrincipal userDetails: UserDetailsImpl, ): ResponseEntity { - // UserIdentity를 통해 역할(Role) 확인 - val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 - ?: throw UserIdentityNotFoundException() - - if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) - } - performanceEventService.deletePerformanceEvent(performanceEventId) return ResponseEntity.noContent().build() } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt index 4b0ca94..8646bd8 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt @@ -1,23 +1,20 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.service.PerformanceHallService -import com.wafflestudio.interpark.user.controller.User -import com.wafflestudio.interpark.user.AuthUser -import com.wafflestudio.interpark.user.UserIdentityNotFoundException -import com.wafflestudio.interpark.user.persistence.UserRole +import com.wafflestudio.interpark.user.controller.UserDetailsImpl import com.wafflestudio.interpark.user.service.UserService import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* @RestController class PerformanceHallController( private val performanceHallService: PerformanceHallService, - private val userService: UserService ) { @GetMapping("/api/v1/performance-hall") fun getPerformanceHall( - @AuthUser user: User, + //@AuthUser user: User, ): ResponseEntity { // Currently, no search val performanceHallList: List = performanceHallService @@ -30,16 +27,8 @@ class PerformanceHallController( @PostMapping("/admin/v1/performance-hall") fun createPerformanceHall( @RequestBody request: CreatePerformanceHallRequest, - @AuthUser user: User + @AuthenticationPrincipal userDetails: UserDetailsImpl, ): ResponseEntity { - // UserIdentity를 통해 역할(Role) 확인 - val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 - ?: throw UserIdentityNotFoundException() - - if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) - } - val newPerformanceHall: PerformanceHall = performanceHallService .createPerformanceHall( @@ -53,16 +42,8 @@ class PerformanceHallController( @DeleteMapping("/admin/v1/performance-hall/{performanceHallId}") fun deletePerformance( @PathVariable performanceHallId: String, - @AuthUser user: User + @AuthenticationPrincipal userDetails: UserDetailsImpl, ): ResponseEntity { - // UserIdentity를 통해 역할(Role) 확인 - val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 - ?: throw UserIdentityNotFoundException() - - if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) - } - performanceHallService.deletePerformanceHall(performanceHallId) return ResponseEntity.noContent().build() } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt index b810793..9257602 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt @@ -4,7 +4,9 @@ import com.wafflestudio.interpark.review.* import com.wafflestudio.interpark.review.service.ReplyService import com.wafflestudio.interpark.user.AuthUser import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.controller.UserDetailsImpl import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* @RestController @@ -13,16 +15,16 @@ class ReplyController( ) { @GetMapping("/api/v1/user/me/reply") fun getRepliesByUser( - @AuthUser user: User + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity{ - val replies = replyService.getRepliesByUser(user) + val replies = replyService.getRepliesByUser(userDetails.getUserId()) return ResponseEntity.ok(replies) } @GetMapping("/api/v1/review/{reviewId}/reply") fun getReplies( @PathVariable reviewId: String, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity{ val replies = replyService.getReplies(reviewId) return ResponseEntity.ok(replies) @@ -32,9 +34,9 @@ class ReplyController( fun createReply( @RequestBody request: CreateReplyRequest, @PathVariable reviewId: String, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - val reply = replyService.createReply(user, reviewId, request.content) + val reply = replyService.createReply(userDetails.getUserId(), reviewId, request.content) return ResponseEntity.status(201).body(reply) } @@ -42,18 +44,18 @@ class ReplyController( fun editReply( @RequestBody request: EditReplyRequest, @PathVariable replyId: String, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - val reply = replyService.editReply(user, replyId, request.content) + val reply = replyService.editReply(userDetails.getUserId(), replyId, request.content) return ResponseEntity.ok(reply) } @DeleteMapping("/api/v1/reply/{replyId}") fun deleteReply( @PathVariable replyId: String, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - replyService.deleteReply(user, replyId) + replyService.deleteReply(userDetails.getUserId(), replyId) return ResponseEntity.noContent().build() } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt index fa15623..2300f0f 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt @@ -4,7 +4,9 @@ import com.wafflestudio.interpark.review.* import com.wafflestudio.interpark.review.service.ReviewService import com.wafflestudio.interpark.user.AuthUser import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.controller.UserDetailsImpl import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* @RestController @@ -14,28 +16,28 @@ class ReviewController( @GetMapping("/api/v1/me/review") fun getMyReviews( - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity{ - val reviews = reviewService.getReviewsByUser(user); + val reviews = reviewService.getReviewsByUser(userDetails.getUserId()); return ResponseEntity.ok(reviews); } @GetMapping("/api/v1/performance/{performanceId}/review") fun getReviews( @PathVariable performanceId: String, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity{ - val reveiws = reviewService.getReviews(performanceId) - return ResponseEntity.ok(reveiws) + val reviews = reviewService.getReviews(performanceId) + return ResponseEntity.ok(reviews) } @PostMapping("/api/v1/performance/{performanceId}/review") fun createReview( @RequestBody request: CreateReviewRequest, @PathVariable performanceId: String, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - val review = reviewService.createReview(user, performanceId, request.rating, request.title, request.content) + val review = reviewService.createReview(userDetails.getUserId(), performanceId, request.rating, request.title, request.content) return ResponseEntity.status(201).body(review) } @@ -43,18 +45,18 @@ class ReviewController( fun editReview( @RequestBody request: EditReviewRequest, @PathVariable reviewId: String, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - val review = reviewService.editReview(user, reviewId, request.rating, request.title, request.content) + val review = reviewService.editReview(userDetails.getUserId(), reviewId, request.rating, request.title, request.content) return ResponseEntity.ok(review) } @DeleteMapping("/api/v1/review/{reviewId}") fun deleteReview( @PathVariable reviewId: String, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - val review = reviewService.deleteReview(user, reviewId) + val review = reviewService.deleteReview(userDetails.getUserId(), reviewId) return ResponseEntity.noContent().build() } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt index 80a1529..8b3d574 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt @@ -1,8 +1,6 @@ package com.wafflestudio.interpark.review.service import com.wafflestudio.interpark.review.* -import com.wafflestudio.interpark.review.controller.Review -import com.wafflestudio.interpark.review.persistence.ReviewEntity import com.wafflestudio.interpark.review.persistence.ReviewRepository import com.wafflestudio.interpark.review.controller.Reply import com.wafflestudio.interpark.review.persistence.ReplyEntity @@ -24,11 +22,10 @@ class ReplyService( private val userRepository: UserRepository, ) { - fun getRepliesByUser(user: User): List { - val authorId = user.id - val replies: List = + fun getRepliesByUser(userId: String): List { + val replies: List = replyRepository - .findByAuthorId(authorId) + .findByAuthorId(userId) .map { Reply.fromEntity(it) } return replies } @@ -44,12 +41,12 @@ class ReplyService( @Transactional fun createReply( - author: User, + authorId: String, reviewId: String, content: String, ): Reply { validateContent(content) - val authorEntity = userRepository.findByIdOrNull(author.id) ?: throw AuthenticateException() + val authorEntity = userRepository.findByIdOrNull(authorId) ?: throw AuthenticateException() val reviewEntity = reviewRepository.findByIdOrNull(reviewId) ?: throw ReviewNotFoundException() val replyEntity = ReplyEntity( @@ -67,13 +64,13 @@ class ReplyService( @Transactional fun editReply( - author: User, + authorId: String, replyId: String, content: String, ): Reply { content?.let { validateContent(it) } val replyEntity = replyRepository.findByIdOrNull(replyId) ?: throw ReplyNotFoundException() - val authorEntity = userRepository.findByIdOrNull(author.id) ?: throw AuthenticateException() + val authorEntity = userRepository.findByIdOrNull(authorId) ?: throw AuthenticateException() if (replyEntity.author.id != authorEntity.id) { throw ReplyPermissionDeniedException() } @@ -85,11 +82,11 @@ class ReplyService( @Transactional fun deleteReply( - author: User, + authorId: String, replyId: String, ) { val replyEntity = replyRepository.findByIdOrNull(replyId) ?: throw ReplyNotFoundException() - val authorEntity = userRepository.findByIdOrNull(author.id) ?: throw AuthenticateException() + val authorEntity = userRepository.findByIdOrNull(authorId) ?: throw AuthenticateException() if (replyEntity.author.id != authorEntity.id) { throw ReplyPermissionDeniedException() } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt index d7c811d..c52637a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt @@ -19,11 +19,10 @@ class ReviewService( private val reviewRepository: ReviewRepository, private val userRepository: UserRepository, ) { - fun getReviewsByUser(user: User): List { - val authorId = user.id; + fun getReviewsByUser(userId: String): List { val reviews: List = reviewRepository - .findByAuthorId(authorId) + .findByAuthorId(userId) .map { Review.fromEntity(it) } return reviews } @@ -39,7 +38,7 @@ class ReviewService( @Transactional fun createReview( - author: User, + authorId: String, performanceId: String, rating: Int, title: String, @@ -50,7 +49,7 @@ class ReviewService( val performanceIdString = performanceId // val performanceEntity = entityManager.getReference(PerformanceEntity::class.java, performanceId) // val performanceEntity = performanceRepository.findByIdOrNull(performanceId) ?: throw PerformanceNotFoundException() - val authorEntity = userRepository.findByIdOrNull(author.id) ?: throw AuthenticateException() + val authorEntity = userRepository.findByIdOrNull(authorId) ?: throw AuthenticateException() val reviewEntity = ReviewEntity( id = "", @@ -70,7 +69,7 @@ class ReviewService( @Transactional fun editReview( - author: User, + authorId: String, reviewId: String, rating: Int?, title: String?, @@ -79,7 +78,7 @@ class ReviewService( content?.let { validateContent(it) } rating?.let { validateRating(it) } val reviewEntity = reviewRepository.findByIdOrNull(reviewId) ?: throw ReviewNotFoundException() - val authorEntity = userRepository.findByIdOrNull(author.id) ?: throw AuthenticateException() + val authorEntity = userRepository.findByIdOrNull(authorId) ?: throw AuthenticateException() if (reviewEntity.author.id != authorEntity.id) { throw ReviewPermissionDeniedException() } @@ -93,11 +92,11 @@ class ReviewService( @Transactional fun deleteReview( - author: User, + authorId: String, reviewId: String, ) { val reviewEntity = reviewRepository.findByIdOrNull(reviewId) ?: throw ReviewNotFoundException() - val authorEntity = userRepository.findByIdOrNull(author.id) ?: throw AuthenticateException() + val authorEntity = userRepository.findByIdOrNull(authorId) ?: throw AuthenticateException() if (reviewEntity.author.id != authorEntity.id) { throw ReviewPermissionDeniedException() } diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt index 7968bda..8dd87e9 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt @@ -3,7 +3,9 @@ package com.wafflestudio.interpark.seat.controller import com.wafflestudio.interpark.seat.service.SeatService import com.wafflestudio.interpark.user.AuthUser import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.controller.UserDetailsImpl import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -26,35 +28,35 @@ class SeatController( @PostMapping("/api/v1/reservation/reserve") fun reserveSeat( @RequestBody request: ReserveSeatRequest, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - val reservationId = seatService.reserveSeat(user, request.reservationId) + val reservationId = seatService.reserveSeat(userDetails.getUserId(), request.reservationId) return ResponseEntity.status(200).body(ReserveSeatResponse(reservationId)) } @GetMapping("/api/v1/me/reservation") fun getMyReservations( - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - val myReservations = seatService.getMyReservations(user) + val myReservations = seatService.getMyReservations(userDetails.getUserId()) return ResponseEntity.ok(GetMyReservationsResponse(myReservations)) } @GetMapping("/api/v1/reservation/detail/{reservationId}") fun getReservedSeatDetail( @PathVariable reservationId: String, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - val reservationDetail = seatService.getReservedSeatDetail(user, reservationId) + val reservationDetail = seatService.getReservedSeatDetail(userDetails.getUserId(), reservationId) return ResponseEntity.status(200).body(GetReservedSeatDetailResponse(reservationDetail)) } @PostMapping("/api/v1/reservation/cancel") fun cancelReservedSeat( @RequestBody request: CancelReserveSeatRequest, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - seatService.cancelReservedSeat(user, request.reservationId) + seatService.cancelReservedSeat(userDetails.getUserId(), request.reservationId) return ResponseEntity.noContent().build() } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt index 1f1a912..8b9778f 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt @@ -12,7 +12,6 @@ import com.wafflestudio.interpark.seat.controller.Seat import com.wafflestudio.interpark.seat.persistence.ReservationRepository import com.wafflestudio.interpark.seat.persistence.SeatRepository import com.wafflestudio.interpark.user.AuthenticateException -import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.persistence.UserRepository import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service @@ -35,10 +34,10 @@ class SeatService( @Transactional fun reserveSeat( - user: User, + userId: String, reservationId: String, ): String { - val targetUser = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() + val targetUser = userRepository.findByIdOrNull(userId) ?: throw AuthenticateException() val targetReservation = reservationRepository.findByIdWithWriteLock(reservationId) ?: throw ReservationNotFoundException() if (targetReservation.reserved) throw ReservedAlreadyException() @@ -52,9 +51,9 @@ class SeatService( } @Transactional - fun getMyReservations(user: User): List { - userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() - val myReservations = reservationRepository.findByUserId(user.id) + fun getMyReservations(userId: String): List { + userRepository.findByIdOrNull(userId) ?: throw AuthenticateException() + val myReservations = reservationRepository.findByUserId(userId) return myReservations.map { reservationEntity -> val performanceEventEntity = reservationEntity.performanceEvent @@ -70,11 +69,11 @@ class SeatService( @Transactional fun getReservedSeatDetail( - user: User, + userId: String, reservationId: String, ): Reservation { val reservationEntity = reservationRepository.findByIdOrNull(reservationId) ?: throw ReservationNotFoundException() - val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() + val userEntity = userRepository.findByIdOrNull(userId) ?: throw AuthenticateException() val reservationUser = reservationEntity.user ?: throw ReservedYetException() if (reservationUser.id != userEntity.id) { throw ReservationPermissionDeniedException() @@ -98,11 +97,11 @@ class SeatService( @Transactional fun cancelReservedSeat( - user: User, + userId: String, reservationId: String, ) { val reservationEntity = reservationRepository.findByIdOrNull(reservationId) ?: throw ReservationNotFoundException() - val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() + val userEntity = userRepository.findByIdOrNull(userId) ?: throw AuthenticateException() val reservationUser = reservationEntity.user ?: throw ReservedYetException() if (reservationUser.id != userEntity.id) { throw ReservationPermissionDeniedException() diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/JwtAuthenticationFilter.kt b/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt similarity index 60% rename from src/main/kotlin/com/wafflestudio/interpark/user/JwtAuthenticationFilter.kt rename to src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt index 7b0b410..5426052 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt @@ -1,5 +1,7 @@ -package com.wafflestudio.interpark.user +package com.wafflestudio.interpark.security +import com.wafflestudio.interpark.user.UserAccessTokenUtil +import com.wafflestudio.interpark.user.controller.UserDetailsImpl import com.wafflestudio.interpark.user.service.UserDetailsServiceImpl import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.context.SecurityContextHolder @@ -33,23 +35,17 @@ class JwtAuthenticationFilter( val subject = userAccessTokenUtil.validateAccessToken(accessToken) if (subject != null) { - // 3) subject를 기반으로 DB에서 유저/권한 정보 조회 (UserIdentityEntity) - val identity = userDetailsService.getUserIdentityByUserId(subject) - - if (identity != null) { - // 예: role -> SimpleGrantedAuthority("ROLE_USER"/"ROLE_ADMIN" 등) - val roleName = "ROLE_${identity.role.name}" // e.g. ROLE_USER - val authorities = listOf(SimpleGrantedAuthority(roleName)) - - // 4) Spring Security에 Authentication 등록 - // principal에는 identity(또는 더 확장된 CustomUserDetails)를 넣어도 됨 - val authentication = UsernamePasswordAuthenticationToken( - identity, // principal - null, // credentials - authorities - ) - SecurityContextHolder.getContext().authentication = authentication - } + // 3) subject(userId)를 기반으로 DB에서 유저/권한 정보 조회 (UserIdentityEntity) + val userDetails = userDetailsService.loadUserByUserId(subject) + + // 4) Spring Security에 Authentication 등록 + // principal에는 identity(또는 더 확장된 CustomUserDetails)를 넣어도 됨 + val authentication = UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.authorities + ) + SecurityContextHolder.getContext().authentication = authentication } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/security/RestAccessDeniedHandler.kt b/src/main/kotlin/com/wafflestudio/interpark/security/RestAccessDeniedHandler.kt new file mode 100644 index 0000000..9ce8b15 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/security/RestAccessDeniedHandler.kt @@ -0,0 +1,29 @@ +package com.wafflestudio.interpark.security + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.stereotype.Component +import org.springframework.security.access.AccessDeniedException + +@Component +class RestAccessDeniedHandler : AccessDeniedHandler { + override fun handle( + request: HttpServletRequest, + response: HttpServletResponse, + accessDeniedException: AccessDeniedException + ) { + response.status = HttpServletResponse.SC_FORBIDDEN // 403 + response.contentType = "application/json" + response.characterEncoding = "UTF-8" + response.writer.write( + """ + { + "error": "Access Denied", + "message": "${accessDeniedException.message}", + "path": "${request.requestURI}" + } + """.trimIndent() + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/SecurityConfig.kt b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt similarity index 57% rename from src/main/kotlin/com/wafflestudio/interpark/config/SecurityConfig.kt rename to src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt index 46bb580..d47b623 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/SecurityConfig.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt @@ -1,8 +1,8 @@ -package com.wafflestudio.interpark.config +package com.wafflestudio.interpark.security -import com.wafflestudio.interpark.user.JwtAuthenticationFilter import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpMethod import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.web.SecurityFilterChain @@ -14,7 +14,6 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic @EnableWebSecurity class SecurityConfig ( private val jwtAuthenticationFilter: JwtAuthenticationFilter, - private val customAuthenticationEntryPoint: CustomAuthenticationEntryPoint ) { @Bean fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { @@ -26,9 +25,19 @@ class SecurityConfig ( } authorizeHttpRequests { // 사용자 권한 - authorize("/api/v1/**", permitAll) + authorize(HttpMethod.GET, "/api/v1/performance/search", permitAll) // 공연 조회 + authorize(HttpMethod.GET, "/api/v1/performance/{performanceId}", permitAll) // 공연 상세정보 반환 + authorize(HttpMethod.GET, "/api/v1/performance-event", permitAll) + authorize(HttpMethod.GET, "/api/v1/performance-event/{performanceId}/{performanceDate}", permitAll) + authorize(HttpMethod.GET, "/api/v1/performance-hall", permitAll) + authorize(HttpMethod.GET, "/api/v1/ping", permitAll) + authorize(HttpMethod.POST, "/api/v1/local/signup", permitAll) + authorize(HttpMethod.POST, "/api/v1/local/signin", permitAll) + authorize(HttpMethod.POST, "/api/v1/auth/signout", permitAll) + authorize(HttpMethod.POST, "/api/v1/auth/refresh_token", permitAll) + authorize(HttpMethod.GET, "/api/v1/seat/{performanceEventId}/available", permitAll) + authorize("/api/v1/**", hasRole("USER")) // 그 외 모두 유저 권한 필요 authorize("/admin/v1/**", hasRole("ADMIN")) - //authorize("/admin/v1/**").hasRole("ADMIN") // Swagger 관련 경로 허용 authorize("/swagger-ui/**", permitAll) @@ -39,7 +48,7 @@ class SecurityConfig ( authorize(anyRequest, authenticated) } exceptionHandling { - authenticationEntryPoint = customAuthenticationEntryPoint // 인증 실패 처리 + accessDeniedHandler = RestAccessDeniedHandler() } addFilterBefore(jwtAuthenticationFilter) } diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt index b55bf93..f4361f9 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt @@ -10,6 +10,7 @@ import io.swagger.v3.oas.annotations.media.Schema import jakarta.servlet.http.Cookie import jakarta.servlet.http.HttpServletResponse import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* @RestController @@ -120,9 +121,17 @@ class UserController( @GetMapping("/api/v1/users/me") fun me( - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl, ): ResponseEntity { - return ResponseEntity.ok(user) + return ResponseEntity.ok( + User( + id = userDetails.getUserId(), + username = userDetails.username, + nickname = userDetails.getNickname(), + phoneNumber = userDetails.getPhoneNumber(), + email = userDetails.getEmail() + ) + ) } @PostMapping("/api/v1/auth/signout") diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt index 2247b6b..b8c6dbc 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt @@ -18,8 +18,7 @@ class UserDetailsImpl ( } override fun getAuthorities(): MutableCollection { - val roleName = "ROLE_${userIdentityEntity.role.name}" // ROLE_USER / ROLE_ADMIN - return mutableListOf(SimpleGrantedAuthority(roleName)) + return mutableListOf(userIdentityEntity.role) } override fun isAccountNonExpired(): Boolean = true @@ -28,8 +27,9 @@ class UserDetailsImpl ( override fun isEnabled(): Boolean = true // 필요하면 convenience 메서드 - fun getUserId(): String? = userIdentityEntity.user.id + fun getUserId(): String = userIdentityEntity.user.id!! fun getNickname(): String = userIdentityEntity.user.nickname fun getEmail(): String = userIdentityEntity.user.email fun getAddress(): String? = userIdentityEntity.user.address + fun getPhoneNumber(): String = userIdentityEntity.user.phoneNumber } \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt index 4b95c25..8ea9c0d 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt @@ -13,13 +13,26 @@ class UserDetailsServiceImpl ( private val userIdentityRepository: UserIdentityRepository, ) : UserDetailsService { override fun loadUserByUsername(username: String): UserDetails { + /* + * 원래는 username으로 찾아야 함. + * 허나 jwt토큰의 subject에 userid를 저장하고 있음 + * userAccessTokenUtil.kt의 genereateAccessToken에서 파라미터명은 username으로 되어 있지만 + * caller(UserService.kt)에서 인자로 userId를 전달하고 있음. + * 이를 해결하려면 RefreshTokenEntity의 필드에 username도 추가해야되는 것으로 보임 + * UserService.kt의 generateAccessToken()에서는 targetUser.username으로 변경하면되지만 + * UserAccessTokenUtil.kt의 generateAccessToken()에서는 storedRefreshToken.username으로 변경 못하기 때문 + * + * 일단, 보류하고 userId로 찾는 것으로 진행하겠음. */ val userIdentityEntity = userIdentityRepository.findByUserUsername(username) ?: throw UserIdentityNotFoundException() return UserDetailsImpl(userIdentityEntity) } - fun getUserIdentityByUserId(userId: String): UserIdentityEntity? { - return userIdentityRepository.findByUserId(userId) + fun loadUserByUserId(userId: String): UserDetails { + val userIdentityEntity = userIdentityRepository.findByUserId(userId) + ?: throw UserIdentityNotFoundException() + + return UserDetailsImpl(userIdentityEntity) } } \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt index 521f61b..bd9c2ec 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt @@ -78,6 +78,7 @@ class UserService( userAccessTokenUtil.removeRefreshToken(refreshToken) } + // spring security로 전가돼서 사실상 호출 안 됨. @Transactional fun authenticate(accessToken: String): User { val userId = userAccessTokenUtil.validateAccessToken(accessToken) ?: throw AuthenticateException() diff --git a/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt index defc230..e2fe61b 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt @@ -280,7 +280,7 @@ class UserIntegrationTest get("/api/v1/users/me") .header("Authorization", "Bearer bad"), ) - .andExpect(status().`is`(401)) + .andExpect(status().`is`(403)) mvc.perform( get("/api/v1/users/me") From 69e5c64b27e3abda594ce90f8bcb924c07bc362f Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Tue, 21 Jan 2025 05:43:51 +0900 Subject: [PATCH 060/162] apply spring security --- .../interpark/security/SecurityConfig.kt | 2 +- .../interpark/UserIntegrationTest.kt | 59 +++++++++++++++++-- src/test/resources/UserApi.http | 15 ++++- 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt index d47b623..9875f27 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt @@ -36,7 +36,7 @@ class SecurityConfig ( authorize(HttpMethod.POST, "/api/v1/auth/signout", permitAll) authorize(HttpMethod.POST, "/api/v1/auth/refresh_token", permitAll) authorize(HttpMethod.GET, "/api/v1/seat/{performanceEventId}/available", permitAll) - authorize("/api/v1/**", hasRole("USER")) // 그 외 모두 유저 권한 필요 + authorize("/api/v1/**", hasAnyRole("USER", "ADMIN")) // 그 외 모두 유저 권한 필요 authorize("/admin/v1/**", hasRole("ADMIN")) // Swagger 관련 경로 허용 diff --git a/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt index e2fe61b..515559d 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt @@ -2,14 +2,13 @@ package com.wafflestudio.interpark import com.fasterxml.jackson.databind.ObjectMapper import com.wafflestudio.interpark.user.UserAccessTokenUtil -import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.persistence.UserRepository +import com.wafflestudio.interpark.user.persistence.UserRole import jakarta.servlet.http.Cookie import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest -import org.springframework.data.repository.findByIdOrNull import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get @@ -26,8 +25,6 @@ class UserIntegrationTest constructor( private val mvc: MockMvc, private val mapper: ObjectMapper, - private val userAccessTokenUtil: UserAccessTokenUtil, - private val userRepository: UserRepository, ) { @Test fun `회원가입시에 유저 이름 혹은 비밀번호가 정해진 규칙에 맞지 않는 경우 400 응답을 내려준다`() { @@ -290,4 +287,58 @@ class UserIntegrationTest .andExpect(jsonPath("$.username").value(username)) .andExpect(jsonPath("$.nickname").value(username)) } + + @Test + fun `관리자가 API 엔드포인트에 접근가능하다`(){ + val (username, password) = "correct5" to "12345678" + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + "nickname" to username, + "phoneNumber" to "010-0000-0000", + "email" to "test@example.com", + "role" to UserRole.ADMIN, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ) + .andExpect(status().`is`(200)) + + val accessToken = + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ) + .andExpect(status().`is`(200)) + .andReturn() + .response.getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + mvc.perform( + get("/api/v1/users/me") + .header("Authorization", "Bearer bad"), + ) + .andExpect(status().`is`(403)) + + mvc.perform( + get("/api/v1/users/me") + .header("Authorization", "Bearer $accessToken"), + ) + .andExpect(status().`is`(200)) + .andExpect(jsonPath("$.username").value(username)) + .andExpect(jsonPath("$.nickname").value(username)) + } } diff --git a/src/test/resources/UserApi.http b/src/test/resources/UserApi.http index 4d908bd..5dc53bd 100644 --- a/src/test/resources/UserApi.http +++ b/src/test/resources/UserApi.http @@ -1,4 +1,4 @@ -### 회원가입 +### 회원가입(USER) POST http://localhost:80/api/v1/local/signup Content-Type: application/json @@ -10,6 +10,19 @@ Content-Type: application/json "email": "test@example.com" } +### 회원가입(ADMIN) +POST http://localhost:80/api/v1/local/signup +Content-Type: application/json + +{ + "username": "adminname", + "password": "12345678", + "nickname": "examplename", + "phoneNumber": "010-0000-0000", + "email": "test@example.com", + "role": "ADMIN" +} + ### 로그인 POST http://localhost:80/api/v1/local/signin Content-Type: application/json From b29b0e1598f6df0f0a4967b52088f0bd7f92c2a8 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Tue, 21 Jan 2025 09:54:28 +0900 Subject: [PATCH 061/162] minor --- .gitignore | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 0361678..9665d0e 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,4 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store - -.env \ No newline at end of file +.DS_Store \ No newline at end of file From 1598f5c7bd36770cc2533cc57168e917f27f5626 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Tue, 21 Jan 2025 09:59:09 +0900 Subject: [PATCH 062/162] remove .env from tracked files --- .env | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .env diff --git a/.env b/.env deleted file mode 100644 index e45ba25..0000000 --- a/.env +++ /dev/null @@ -1,3 +0,0 @@ -SPRING_DATASOURCE_URL: "jdbc:mysql://mysql-db:3306/testdb" -SPRING_DATASOURCE_USERNAME: "user" -SPRING_DATASOURCE_PASSWORD: "somepassword" \ No newline at end of file From a0e7281f809ab0d531552cbbd7c6a21e838fc2ab Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Tue, 21 Jan 2025 10:03:37 +0900 Subject: [PATCH 063/162] gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9665d0e..7159cd5 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,4 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store From cb2d9031cb62e6bbd7a9ed5243cb3fc55667f07d Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Tue, 21 Jan 2025 10:22:28 +0900 Subject: [PATCH 064/162] remove unused method --- .../com/wafflestudio/interpark/user/service/UserService.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt index bd9c2ec..29dc370 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt @@ -90,8 +90,4 @@ class UserService( fun refreshAccessToken(refreshToken: String): Pair { return userAccessTokenUtil.refreshAccessToken(refreshToken) ?: throw AuthenticateException() } - - fun getUserIdentityByUserId(userId: String): UserIdentityEntity? { - return userIdentityRepository.findByUserId(userId) - } } From 6c227f8aadb9f6f378c00482ebcccb6041c133ae Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Tue, 21 Jan 2025 10:43:27 +0900 Subject: [PATCH 065/162] minor --- .../interpark/user/service/UserDetailsServiceImpl.kt | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt index 8ea9c0d..cf4a54c 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt @@ -13,16 +13,6 @@ class UserDetailsServiceImpl ( private val userIdentityRepository: UserIdentityRepository, ) : UserDetailsService { override fun loadUserByUsername(username: String): UserDetails { - /* - * 원래는 username으로 찾아야 함. - * 허나 jwt토큰의 subject에 userid를 저장하고 있음 - * userAccessTokenUtil.kt의 genereateAccessToken에서 파라미터명은 username으로 되어 있지만 - * caller(UserService.kt)에서 인자로 userId를 전달하고 있음. - * 이를 해결하려면 RefreshTokenEntity의 필드에 username도 추가해야되는 것으로 보임 - * UserService.kt의 generateAccessToken()에서는 targetUser.username으로 변경하면되지만 - * UserAccessTokenUtil.kt의 generateAccessToken()에서는 storedRefreshToken.username으로 변경 못하기 때문 - * - * 일단, 보류하고 userId로 찾는 것으로 진행하겠음. */ val userIdentityEntity = userIdentityRepository.findByUserUsername(username) ?: throw UserIdentityNotFoundException() From 2c053fa55badb52fe0a2e44beb38e5e73c8e7ab8 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Tue, 21 Jan 2025 10:49:52 +0900 Subject: [PATCH 066/162] minor --- .../kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt index 515559d..0d4f11b 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt @@ -236,7 +236,7 @@ class UserIntegrationTest } @Test - fun `잘못된 인증 토큰으로 인증시 401 응답을 내려준다`() { + fun `잘못된 인증 토큰으로 접근 시 403 응답을 내려준다`() { val (username, password) = "correct4" to "12345678" mvc.perform( post("/api/v1/local/signup") From cb67952cf7d35fba1684a5f596bc0e604db46583 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Tue, 21 Jan 2025 11:07:08 +0900 Subject: [PATCH 067/162] add new line at EOF --- .../wafflestudio/interpark/security/JwtAuthenticationFilter.kt | 2 +- .../wafflestudio/interpark/security/RestAccessDeniedHandler.kt | 2 +- .../com/wafflestudio/interpark/security/SecurityConfig.kt | 2 +- .../wafflestudio/interpark/user/controller/UserDetailsImpl.kt | 2 +- .../interpark/user/persistence/UserIdentityEntity.kt | 2 +- .../interpark/user/service/UserDetailsServiceImpl.kt | 2 +- src/test/resources/SeatApi.http | 2 +- src/test/resources/UserApi.http | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt index 5426052..daea92c 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt @@ -52,4 +52,4 @@ class JwtAuthenticationFilter( // 5) 체인 계속 진행 chain.doFilter(request, response) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/security/RestAccessDeniedHandler.kt b/src/main/kotlin/com/wafflestudio/interpark/security/RestAccessDeniedHandler.kt index 9ce8b15..6477647 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/security/RestAccessDeniedHandler.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/security/RestAccessDeniedHandler.kt @@ -26,4 +26,4 @@ class RestAccessDeniedHandler : AccessDeniedHandler { """.trimIndent() ) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt index 9875f27..bd203a9 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt @@ -54,4 +54,4 @@ class SecurityConfig ( } return http.build() } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt index b8c6dbc..66265e8 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt @@ -32,4 +32,4 @@ class UserDetailsImpl ( fun getEmail(): String = userIdentityEntity.user.email fun getAddress(): String? = userIdentityEntity.user.address fun getPhoneNumber(): String = userIdentityEntity.user.phoneNumber -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt index 56c5852..81cd197 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt @@ -33,4 +33,4 @@ enum class UserRole : GrantedAuthority { override fun getAuthority(): String { return "ROLE_$name" // Spring Security에서 권장하는 ROLE_ 접두사 } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt index cf4a54c..307f0d0 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt @@ -25,4 +25,4 @@ class UserDetailsServiceImpl ( return UserDetailsImpl(userIdentityEntity) } -} \ No newline at end of file +} diff --git a/src/test/resources/SeatApi.http b/src/test/resources/SeatApi.http index 5939758..f102ad7 100644 --- a/src/test/resources/SeatApi.http +++ b/src/test/resources/SeatApi.http @@ -33,4 +33,4 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkODUyNjcwMy0zM2Y2LTRmM2ItY ### 가능한 좌석 받기 GET http://localhost:80/api/v1/seat/eade9900-a141-4532-bd2c-c362ceb7c8c0/available -Accept: application/json \ No newline at end of file +Accept: application/json diff --git a/src/test/resources/UserApi.http b/src/test/resources/UserApi.http index 5dc53bd..c246126 100644 --- a/src/test/resources/UserApi.http +++ b/src/test/resources/UserApi.http @@ -42,4 +42,4 @@ Accept: application/json ### 인증 토큰으로 유저 프로필 조회 GET http://localhost:80/api/v1/users/me -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJiY2JlMjI5Yi0zYjgyLTRkM2MtOWFjNS03ZWYzZTUyMjViNWYiLCJpYXQiOjE3MzYzMjA2MDksImV4cCI6MTczNjMyMTUwOX0.RJHC9qQvBnt_q4aS81qdpTrTnXfS-qQ-wMygRFXXg8E \ No newline at end of file +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJiY2JlMjI5Yi0zYjgyLTRkM2MtOWFjNS03ZWYzZTUyMjViNWYiLCJpYXQiOjE3MzYzMjA2MDksImV4cCI6MTczNjMyMTUwOX0.RJHC9qQvBnt_q4aS81qdpTrTnXfS-qQ-wMygRFXXg8E From dd82fd3f9bc339ea70a4a94b11df2ef31ec738c0 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Tue, 21 Jan 2025 11:55:27 +0900 Subject: [PATCH 068/162] modify jwtfilter comment --- .../interpark/security/JwtAuthenticationFilter.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt index daea92c..6f53488 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt @@ -30,16 +30,14 @@ class JwtAuthenticationFilter( val accessToken = header.split(" ")[1] // 2) UserAccessTokenUtil로 토큰 유효성 검사 - // 유효하면 userId가 반환되고, - // 유효하지 않으면 null + // 유효하면 userId가 반환되고, 유효하지 않으면 null val subject = userAccessTokenUtil.validateAccessToken(accessToken) if (subject != null) { - // 3) subject(userId)를 기반으로 DB에서 유저/권한 정보 조회 (UserIdentityEntity) + // 3) subject(userId)를 기반으로 DB에서 유저/권한 정보 조회 (UserDetails) val userDetails = userDetailsService.loadUserByUserId(subject) // 4) Spring Security에 Authentication 등록 - // principal에는 identity(또는 더 확장된 CustomUserDetails)를 넣어도 됨 val authentication = UsernamePasswordAuthenticationToken( userDetails, null, From 9d19828721297027e495f440648f5b1e0047390c Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Tue, 21 Jan 2025 17:11:33 +0900 Subject: [PATCH 069/162] convert Instant to KoreanLocalDateTime in PerformanceEventDTO --- .../interpark/config/DataInitializer.kt | 5 +++-- .../performance/controller/PerformanceEvent.kt | 16 +++++++++++----- .../controller/PerformanceEventController.kt | 5 +++-- .../service/PerformanceEventService.kt | 8 ++++---- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index cf74dd0..004f0e6 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -10,6 +10,7 @@ import com.wafflestudio.interpark.performance.service.PerformanceHallService import com.wafflestudio.interpark.performance.service.PerformanceService import org.springframework.boot.CommandLineRunner import org.springframework.context.annotation.Configuration +import java.time.LocalDateTime @Configuration class DataInitializer( @@ -270,8 +271,8 @@ class DataInitializer( performanceEventService.createPerformanceEvent( performanceId = performance.id!!, performanceHallId = hall.id!!, - startAt = startAt, - endAt = endAt + startAt = LocalDateTime.parse(startAt), + endAt = LocalDateTime.parse(endAt) ) } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEvent.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEvent.kt index a90508b..c4ab750 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEvent.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEvent.kt @@ -2,24 +2,30 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.persistence.PerformanceEventEntity import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId data class PerformanceEvent( val id: String, val performanceId: String, val performanceHallId: String, - val startAt: Instant, - val endAt: Instant, + val startAt: LocalDateTime, + val endAt: LocalDateTime, ) { companion object { fun fromEntity(entity: PerformanceEventEntity): PerformanceEvent { return PerformanceEvent( - id = entity.id!!, + id = entity.id, performanceId = entity.performance.id!!, performanceHallId = entity.performanceHall.id!!, - startAt = entity.startAt, - endAt = entity.endAt, + startAt = convertInstantToKoreanTime(entity.startAt), + endAt = convertInstantToKoreanTime(entity.endAt), ) } + + private fun convertInstantToKoreanTime(instant: Instant): LocalDateTime { + return LocalDateTime.ofInstant(instant, ZoneId.of("Asia/Seoul")) + } } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt index 306273e..adf7bfd 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt @@ -7,6 +7,7 @@ import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* import java.time.LocalDate +import java.time.LocalDateTime @RestController class PerformanceEventController( @@ -70,8 +71,8 @@ typealias GetPerformanceEventResponse = List data class CreatePerformanceEventRequest( val performanceId: String, val performanceHallId: String, - val startAt: String, - val endAt: String, + val startAt: LocalDateTime, + val endAt: LocalDateTime, ) typealias CreatePerformanceEventResponse = PerformanceEvent \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt index 75c1ab4..deb6c11 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt @@ -43,16 +43,16 @@ class PerformanceEventService( .map { PerformanceEvent.fromEntity(it) }; } - fun parseKoreanTimeToInstant(koreanTime: String): Instant { + fun parseKoreanTimeToInstant(koreanTime: LocalDateTime): Instant { val koreanZone = ZoneId.of("Asia/Seoul") - return LocalDateTime.parse(koreanTime).atZone(koreanZone).toInstant() + return koreanTime.atZone(koreanZone).toInstant() } fun createPerformanceEvent( performanceId: String, performanceHallId: String, - startAt: String, - endAt: String, + startAt: LocalDateTime, + endAt: LocalDateTime, ): PerformanceEvent { val performanceEntity: PerformanceEntity = performanceRepository.findByIdOrNull(performanceId) ?: throw PerformanceNotFoundException() From 01ce970b1320e3783d1fc044393427df8bcc450d Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Tue, 21 Jan 2025 19:55:12 +0900 Subject: [PATCH 070/162] add permission --- .../performance/controller/PerformanceController.kt | 7 ------- .../performance/controller/PerformanceEventController.kt | 2 -- .../performance/controller/PerformanceHallController.kt | 2 -- .../interpark/review/controller/ReplyController.kt | 4 ---- .../interpark/review/controller/ReviewController.kt | 8 ++------ .../com/wafflestudio/interpark/security/SecurityConfig.kt | 2 ++ 6 files changed, 4 insertions(+), 21 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index e5d6ba8..f5867d2 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -3,12 +3,7 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import io.swagger.v3.oas.annotations.Operation import com.wafflestudio.interpark.performance.service.PerformanceService -import com.wafflestudio.interpark.user.AuthUser -import com.wafflestudio.interpark.user.UserIdentityNotFoundException -import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.controller.UserDetailsImpl -import com.wafflestudio.interpark.user.persistence.UserRole -import com.wafflestudio.interpark.user.service.UserService import jakarta.validation.Valid import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull @@ -16,12 +11,10 @@ import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* -import java.time.LocalDate @RestController class PerformanceController( private val performanceService: PerformanceService, - private val userService: UserService, ) { @GetMapping("/api/v1/performance/search") @Operation( diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt index adf7bfd..92caa1a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt @@ -15,7 +15,6 @@ class PerformanceEventController( ) { @GetMapping("/api/v1/performance-event") fun getPerformanceEvent( - //@AuthUser user: User, ): ResponseEntity { // Currently, no search val performanceEventList: List = performanceEventService @@ -25,7 +24,6 @@ class PerformanceEventController( @GetMapping("/api/v1/performance-event/{performanceId}/{performanceDate}") fun getPerformanceEventFromDate( - //@AuthUser user: User, @PathVariable performanceId: String, @PathVariable performanceDate: String, ): ResponseEntity { diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt index 8646bd8..0c0eb6b 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt @@ -2,7 +2,6 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.service.PerformanceHallService import com.wafflestudio.interpark.user.controller.UserDetailsImpl -import com.wafflestudio.interpark.user.service.UserService import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -14,7 +13,6 @@ class PerformanceHallController( ) { @GetMapping("/api/v1/performance-hall") fun getPerformanceHall( - //@AuthUser user: User, ): ResponseEntity { // Currently, no search val performanceHallList: List = performanceHallService diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt index 9257602..e78bcda 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt @@ -1,9 +1,6 @@ package com.wafflestudio.interpark.review.controller -import com.wafflestudio.interpark.review.* import com.wafflestudio.interpark.review.service.ReplyService -import com.wafflestudio.interpark.user.AuthUser -import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.controller.UserDetailsImpl import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -24,7 +21,6 @@ class ReplyController( @GetMapping("/api/v1/review/{reviewId}/reply") fun getReplies( @PathVariable reviewId: String, - @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity{ val replies = replyService.getReplies(reviewId) return ResponseEntity.ok(replies) diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt index 2300f0f..b20418a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt @@ -1,9 +1,6 @@ package com.wafflestudio.interpark.review.controller -import com.wafflestudio.interpark.review.* import com.wafflestudio.interpark.review.service.ReviewService -import com.wafflestudio.interpark.user.AuthUser -import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.controller.UserDetailsImpl import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -18,14 +15,13 @@ class ReviewController( fun getMyReviews( @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity{ - val reviews = reviewService.getReviewsByUser(userDetails.getUserId()); - return ResponseEntity.ok(reviews); + val reviews = reviewService.getReviewsByUser(userDetails.getUserId()) + return ResponseEntity.ok(reviews) } @GetMapping("/api/v1/performance/{performanceId}/review") fun getReviews( @PathVariable performanceId: String, - @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity{ val reviews = reviewService.getReviews(performanceId) return ResponseEntity.ok(reviews) diff --git a/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt index bd203a9..dd4aa4f 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt @@ -36,6 +36,8 @@ class SecurityConfig ( authorize(HttpMethod.POST, "/api/v1/auth/signout", permitAll) authorize(HttpMethod.POST, "/api/v1/auth/refresh_token", permitAll) authorize(HttpMethod.GET, "/api/v1/seat/{performanceEventId}/available", permitAll) + authorize(HttpMethod.GET, "/api/v1/performance/{performanceId}/review", permitAll) + authorize(HttpMethod.GET, "/api/v1/review/{reviewId}/reply", permitAll) authorize("/api/v1/**", hasAnyRole("USER", "ADMIN")) // 그 외 모두 유저 권한 필요 authorize("/admin/v1/**", hasRole("ADMIN")) From dfe223226922b2f8df9dfa605ba5c33a93d463b7 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Wed, 22 Jan 2025 00:06:43 +0900 Subject: [PATCH 071/162] =?UTF-8?q?feat:=20Review/Reply=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review와 reply 마무리 --- .../interpark/review/controller/Reply.kt | 2 +- .../interpark/review/controller/Review.kt | 13 +- .../review/controller/ReviewController.kt | 40 +-- .../review/persistence/ReplyRepository.kt | 1 + .../review/persistence/ReviewEntity.kt | 9 +- .../review/persistence/ReviewRepository.kt | 7 + .../interpark/review/service/ReplyService.kt | 5 + .../interpark/review/service/ReviewService.kt | 76 +++--- .../interpark/ReplyIntegrationTest.kt | 148 +++++++++- .../interpark/ReviewIntegrationTest.kt | 13 +- .../interpark/ReviewLikeIntegrationTest.kt | 255 ++++++++++++++++++ 11 files changed, 502 insertions(+), 67 deletions(-) create mode 100644 src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt index 26f324c..b533d39 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt @@ -14,7 +14,7 @@ data class Reply( fun fromEntity(entity: ReplyEntity): Reply { return Reply( id = entity.id!!, - author = entity.author.id!!, + author = entity.author.nickname, content = entity.content, createdAt = entity.createdAt, updatedAt = entity.updatedAt, diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt index 0c10f8e..3f91d49 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt @@ -6,27 +6,26 @@ import java.time.Instant data class Review( val id: String, val author: String, - val performance: String, - // val stageId: String, val rating: Int, val title: String, val content: String, val createdAt: Instant, val updatedAt: Instant, - // val replyId: List + val likeCount: Int, + val replyCount: Int, ) { companion object { - fun fromEntity(entity: ReviewEntity): Review { + fun fromEntity(entity: ReviewEntity, replyCount: Int): Review { return Review( id = entity.id!!, - author = entity.author.id!!, - performance = entity.performanceId, - // stageId = entity.stageId, + author = entity.author.nickname, rating = entity.rating, title = entity.title, content = entity.content, createdAt = entity.createdAt, updatedAt = entity.updatedAt, + likeCount = entity.reviewLikes.size, + replyCount = replyCount, ) } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt index fa15623..9474c91 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt @@ -1,6 +1,7 @@ package com.wafflestudio.interpark.review.controller import com.wafflestudio.interpark.review.* +import com.wafflestudio.interpark.review.service.ReplyService import com.wafflestudio.interpark.review.service.ReviewService import com.wafflestudio.interpark.user.AuthUser import com.wafflestudio.interpark.user.controller.User @@ -10,6 +11,7 @@ import org.springframework.web.bind.annotation.* @RestController class ReviewController( private val reviewService: ReviewService, + private val replyService: ReplyService, ) { @GetMapping("/api/v1/me/review") @@ -25,8 +27,8 @@ class ReviewController( @PathVariable performanceId: String, @AuthUser user: User, ): ResponseEntity{ - val reveiws = reviewService.getReviews(performanceId) - return ResponseEntity.ok(reveiws) + val reviews = reviewService.getReviews(performanceId) + return ResponseEntity.ok(reviews) } @PostMapping("/api/v1/performance/{performanceId}/review") @@ -58,23 +60,23 @@ class ReviewController( return ResponseEntity.noContent().build() } - // @PostMapping("/api/v1/reviews/{reviewId}/like") - // fun likeReview( - // @PathVariable reviewId: String, - // @AuthUser user: User, - // ): ResponseEntity { - // reviewService.likeReview(user, reviewId) - // return ResponseEntity.noContent().build() - // } - - // @PostMapping("/api/v1/reviews/{reviewId}/unlike") - // fun unlikeReview( - // @PathVariable reviewId: String, - // @AuthUser user: User, - // ): ResponseEntity { - // reviewService.unlikeReview(user, reviewId) - // return ResponseEntity.noContent().build() - // } + @PostMapping("/api/v1/review/{reviewId}/like") + fun likeReview( + @PathVariable reviewId: String, + @AuthUser user: User, + ): ResponseEntity { + reviewService.likeReview(user, reviewId) + return ResponseEntity.noContent().build() + } + + @DeleteMapping("/api/v1/review/{reviewId}/like") + fun cancelLikeReview( + @PathVariable reviewId: String, + @AuthUser user: User, + ): ResponseEntity { + reviewService.cancelLikeReview(user, reviewId) + return ResponseEntity.noContent().build() + } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt index 4fa53b6..3196931 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt @@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository interface ReplyRepository : JpaRepository { fun findByReviewId(reviewId: String): List fun findByAuthorId(authorId: String): List + fun countByReviewId(reviewId: String): Int } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt index 071983d..bf42588 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt @@ -1,5 +1,6 @@ package com.wafflestudio.interpark.review.persistence +import com.wafflestudio.interpark.performance.persistence.PerformanceEntity import com.wafflestudio.interpark.user.persistence.UserEntity import jakarta.persistence.* import java.time.Instant @@ -15,8 +16,9 @@ class ReviewEntity( @JoinColumn(name = "user_id", nullable = false) val author: UserEntity, - @Column(name = "performance_id", nullable = false) - val performanceId: String, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "performance_id", nullable = false) + val performance: PerformanceEntity, @Column(name = "rating", nullable = false) var rating: Int, @@ -32,4 +34,7 @@ class ReviewEntity( @Column(name = "updated_at", nullable = false) var updatedAt: Instant = Instant.now(), + + @OneToMany(mappedBy = "review") + var reviewLikes: List = emptyList(), ) diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt index a1a7fd4..3c0855e 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt @@ -1,8 +1,15 @@ package com.wafflestudio.interpark.review.persistence +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 interface ReviewRepository : JpaRepository { fun findByPerformanceId(performanceId: String): List fun findByAuthorId(authorId: String): List + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM ReviewEntity r WHERE r.id = :id") + fun findByIdWithWriteLock(id: String): ReviewEntity? } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt index 80a1529..47d9852 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt @@ -42,6 +42,11 @@ class ReplyService( return replies } + fun countReplies(reviewId: String): Int { + val replyCount = replyRepository.countByReviewId(reviewId) + return replyCount + } + @Transactional fun createReply( author: User, diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt index d7c811d..fd24bfe 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt @@ -1,8 +1,12 @@ package com.wafflestudio.interpark.review.service +import com.wafflestudio.interpark.performance.PerformanceNotFoundException +import com.wafflestudio.interpark.performance.persistence.PerformanceRepository import com.wafflestudio.interpark.review.* import com.wafflestudio.interpark.review.controller.Review import com.wafflestudio.interpark.review.persistence.ReviewEntity +import com.wafflestudio.interpark.review.persistence.ReviewLikeEntity +import com.wafflestudio.interpark.review.persistence.ReviewLikeRepository import com.wafflestudio.interpark.review.persistence.ReviewRepository import com.wafflestudio.interpark.user.AuthenticateException import com.wafflestudio.interpark.user.controller.User @@ -15,16 +19,18 @@ import java.time.Instant @Service class ReviewService( - private val entityManager: EntityManager, + private val performanceRepository: PerformanceRepository, private val reviewRepository: ReviewRepository, private val userRepository: UserRepository, + private val reviewLikeRepository: ReviewLikeRepository, + private val replyService: ReplyService, ) { fun getReviewsByUser(user: User): List { val authorId = user.id; val reviews: List = reviewRepository .findByAuthorId(authorId) - .map { Review.fromEntity(it) } + .map { Review.fromEntity(it, replyService.countReplies(it.id)) } return reviews } @@ -33,7 +39,7 @@ class ReviewService( val reviews: List = reviewRepository .findByPerformanceId(performanceId) - .map { Review.fromEntity(it) } + .map { Review.fromEntity(it, replyService.countReplies(it.id)) } return reviews } @@ -47,16 +53,14 @@ class ReviewService( ): Review { validateContent(content) validateRating(rating) - val performanceIdString = performanceId - // val performanceEntity = entityManager.getReference(PerformanceEntity::class.java, performanceId) - // val performanceEntity = performanceRepository.findByIdOrNull(performanceId) ?: throw PerformanceNotFoundException() + + val performanceEntity = performanceRepository.findByIdOrNull(performanceId) ?: throw PerformanceNotFoundException() val authorEntity = userRepository.findByIdOrNull(author.id) ?: throw AuthenticateException() val reviewEntity = ReviewEntity( id = "", author = authorEntity, - performanceId = performanceId, - // performance = performanceEntity, + performance = performanceEntity, title = title, content = content, rating = rating, @@ -65,7 +69,7 @@ class ReviewService( ).let { reviewRepository.save(it) } - return Review.fromEntity(reviewEntity) + return Review.fromEntity(reviewEntity, replyService.countReplies(reviewEntity.id)) } @Transactional @@ -88,7 +92,7 @@ class ReviewService( content?.let { reviewEntity.content = it } reviewEntity.updatedAt = Instant.now() reviewRepository.save(reviewEntity) - return Review.fromEntity(reviewEntity) + return Review.fromEntity(reviewEntity, replyService.countReplies(reviewEntity.id)) } @Transactional @@ -119,31 +123,31 @@ class ReviewService( } } - // @Transactional - // fun likeReview( - // user: User, - // reviewId: String, - // ) { - // val reviewEntity = reviewRepository.findByIdWithWriteLock(reviewId) ?: throw ReviewNotFoundException() - // val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() - // if (reviewEntity.reviewLikes.any { it.user.id == userEntity.id }) { - // return - // } - // val reviewLikeEntity = reviewLikeRepository.save(ReviewLikeEntity(review = reviewEntity, user = userEntity, createdAt = Instant.now(), updatedAt = Instant.now())) - // reviewEntity.reviewLikes += reviewLikeEntity - // reviewRepository.save(reviewEntity) - // } + @Transactional + fun likeReview( + user: User, + reviewId: String, + ) { + val reviewEntity = reviewRepository.findByIdWithWriteLock(reviewId) ?: throw ReviewNotFoundException() + val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() + if (reviewEntity.reviewLikes.any { it.user.id == userEntity.id }) { + return + } + val reviewLikeEntity = reviewLikeRepository.save(ReviewLikeEntity(review = reviewEntity, user = userEntity)) + reviewEntity.reviewLikes += reviewLikeEntity + reviewRepository.save(reviewEntity) + } - // @Transactional - // fun unlikeReview( - // user: User, - // reviewId: String, - // ) { - // val reviewEntity = reviewRepository.findByIdWithWriteLock(reviewId) ?: throw ReviewNotFoundException() - // val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() - // val reviewToDelete = reviewEntity.reviewLikes.find { it.user.id == userEntity.id } ?: return - // reviewEntity.reviewLikes -= reviewToDelete - // reviewLikeRepository.delete(reviewToDelete) - // reviewRepository.save(reviewEntity) - // } + @Transactional + fun cancelLikeReview( + user: User, + reviewId: String, + ) { + val reviewEntity = reviewRepository.findByIdWithWriteLock(reviewId) ?: throw ReviewNotFoundException() + val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() + val reviewLikeToDelete = reviewEntity.reviewLikes.find { it.user.id == userEntity.id } ?: return + reviewEntity.reviewLikes -= reviewLikeToDelete + reviewLikeRepository.delete(reviewLikeToDelete) + reviewRepository.save(reviewEntity) + } } \ No newline at end of file diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt index 8691045..625afd1 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt @@ -68,7 +68,18 @@ class ReplyIntegrationTest .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it).get("accessToken").asText() } - performanceId = "sample-performance" + //테스트 용으로 아무 공연 Id를 하나 가져온다 + performanceId = + mvc.perform( + get("/api/v1/performance/search") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performances = mapper.readTree(it) + performances[0].get("id").asText() + } // 3️⃣ 리뷰 생성 (테스트용) reviewId = @@ -240,4 +251,139 @@ class ReplyIntegrationTest ).andExpect(status().`is`(401)) .andExpect(jsonPath("$.error").value("Unauthorized Access To Reply")) } + + @Test + fun `댓글을 달면 댓글수가 증가한다`() { + var reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + var targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("replyCount").asText() == "0") { "expected 0 replyCount but ${targetReview!!.get("reply").asText()}"} + + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "Great review! I totally agree."), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + + reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("replyCount").asText() == "1") { "expected 1 replyCount but ${targetReview!!.get("replyCount").asText()}"} + } + + @Test + fun `댓글은 여러 개를 달 수 있다`() { + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "Great review! I totally agree."), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "I can't stop thinking about this review! I totally agree again."), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "Again and Again and Again"), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + + val reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + val targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("replyCount").asText() == "3") { "expected 3 replyCount but ${targetReview!!.get("replyCount").asText()}"} + } + + @Test + fun `댓글을 지우면 댓글수가 줄어든다`() { + replyId = + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "Great review! I totally agree."), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("id").asText() } + + var reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + var targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("replyCount").asText() == "1") { "expected 1 replyCount but ${targetReview!!.get("replyCount").asText()}"} + + mvc.perform( + delete("/api/v1/reply/$replyId") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(204)) + + reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("replyCount").asText() == "0") { "expected 0 replyCount but ${targetReview!!.get("likeCount").asText()}"} + + } } diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt index 391de23..31147bf 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt @@ -68,7 +68,18 @@ class ReviewIntegrationTest .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it).get("accessToken").asText() } - performanceId = "sample-performance" // 가상의 공연 ID + //테스트 용으로 아무 공연 Id를 하나 가져온다 + performanceId = + mvc.perform( + get("/api/v1/performance/search") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performances = mapper.readTree(it) + performances[0].get("id").asText() + } } @Test diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt new file mode 100644 index 0000000..ff570be --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt @@ -0,0 +1,255 @@ +package com.wafflestudio.interpark.review + +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Transactional +class ReviewLikeIntegrationTest +@Autowired +constructor( + private val mvc: MockMvc, + private val mapper: ObjectMapper, +) { + private lateinit var accessToken: String + private lateinit var otherAccessToken: String + private lateinit var performanceId: String + private lateinit var reviewId: String + + @BeforeEach + fun setUp() { + val username = UUID.randomUUID().toString().take(8) + val password = "password123" + + // 1️⃣ 회원가입 + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + "nickname" to "reviewer", + "phoneNumber" to "010-0000-0000", + "email" to "reviewer@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + // 2️⃣ 로그인 → 토큰 획득 + accessToken = + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + //다른 사용자로도 좋아요가 되는지 체크 + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "otherUser1", + "password" to "goodPassword", + "nickname" to "otherUser1", + "phoneNumber" to "010-1234-5678", + "email" to "other@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + otherAccessToken = + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "otherUser1", + "password" to "goodPassword", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + //테스트 용으로 아무 공연 Id를 하나 가져온다 + performanceId = + mvc.perform( + get("/api/v1/performance/search") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performances = mapper.readTree(it) + performances[0].get("id").asText() + } + + reviewId = + mvc.perform( + post("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf( + "rating" to 5, + "title" to "Great Performance!", + "content" to "Absolutely amazing. Highly recommend!", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("id").asText() } + } + + @Test + fun `좋아요를 하면 좋아요 수가 증가한다`() { + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + var reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + var targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("likeCount").asText() == "1") { "expected 1 likeCount but ${targetReview!!.get("likeCount").asText()}"} + + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $otherAccessToken") + ).andExpect(status().`is`(204)) + + reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("likeCount").asText() == "2") { "expected 2 likeCount but ${targetReview!!.get("likeCount").asText()}"} + } + + @Test + fun `한 사람이 좋아요를 여러 번 눌러도 좋아요는 1만 증가한다`() { + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + val reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + val targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("likeCount").asText() == "1") { "expected 1 likeCount but ${targetReview!!.get("likeCount").asText()}"} + } + + @Test + fun `좋아요를 취소하면 좋아요 수가 줄어든다`() { + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + mvc.perform( + delete("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + var reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + var targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("likeCount").asText() == "0") { "expected 0 likeCount but ${targetReview!!.get("likeCount").asText()}"} + + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + mvc.perform( + delete("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $otherAccessToken") + ).andExpect(status().`is`(204)) + + // otherUser은 좋아요를 누르지 않았으니 취소해도 좋아요가 줄어들지 않는다 + // 좋아요를 누르지 않은 채로 취소해도 버그는 나지 않는다 + reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("likeCount").asText() == "1") { "expected 1 likeCount but ${targetReview!!.get("likeCount").asText()}"} + } +} From 65c5fb498d0d752e7078548cff5e446e9a1ffe70 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Wed, 22 Jan 2025 00:08:12 +0900 Subject: [PATCH 072/162] =?UTF-8?q?comment:=20user=20=EC=84=A4=EB=AA=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit username과 password의 조건 설명 추가 --- .../com/wafflestudio/interpark/user/controller/UserController.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt index 45687cd..c5de905 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt @@ -31,6 +31,7 @@ class UserController( description = """ 새로운 사용자를 등록합니다. 사용자 이름, 비밀번호, 닉네임, 이메일, 전화번호를 입력받아 저장합니다. + useraname은 6~20자, password는 8~12자를 만족해야 합니다 요청이 유효하지 않은 경우 또는 사용자 이름이 중복된 경우 적절한 에러 메시지를 반환합니다. """, responses = [ From d1f129e9e07edce7ae49c61226c2508f25583820 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Wed, 22 Jan 2025 00:19:25 +0900 Subject: [PATCH 073/162] =?UTF-8?q?chore:=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=BD=94=EB=93=9C=20=EC=A7=80=EC=9A=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/wafflestudio/interpark/review/service/ReplyService.kt | 1 - .../com/wafflestudio/interpark/review/service/ReviewService.kt | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt index 47d9852..b415328 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt @@ -33,7 +33,6 @@ class ReplyService( return replies } - // TODO: 검색기능 구현해야 함 fun getReplies(reviewId: String): List { val replies: List = replyRepository diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt index fd24bfe..44d35a2 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt @@ -26,7 +26,7 @@ class ReviewService( private val replyService: ReplyService, ) { fun getReviewsByUser(user: User): List { - val authorId = user.id; + val authorId = user.id val reviews: List = reviewRepository .findByAuthorId(authorId) @@ -34,7 +34,6 @@ class ReviewService( return reviews } - // TODO: 검색기능 구현해야 함 fun getReviews(performanceId: String): List { val reviews: List = reviewRepository From eb299b862235079047f10a65c30ce1600d4d8b7d Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Wed, 22 Jan 2025 18:16:21 +0900 Subject: [PATCH 074/162] feat: Sort Reviews and Replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET을 통해 리뷰나 댓글을 조회할 때 최신순으로 반환한다 --- .../review/controller/ReplyController.kt | 3 +- .../review/controller/ReviewController.kt | 1 - .../review/persistence/ReplyLikeEntity.kt | 25 ----- .../review/persistence/ReplyLikeRepository.kt | 18 ---- .../review/persistence/ReplyRepository.kt | 3 + .../review/persistence/ReviewRepository.kt | 2 + .../interpark/ReplyIntegrationTest.kt | 90 ++++++++++++++++ .../interpark/ReviewIntegrationTest.kt | 100 +++++++++++++++++- 8 files changed, 195 insertions(+), 47 deletions(-) delete mode 100644 src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeEntity.kt delete mode 100644 src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeRepository.kt diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt index b810793..6950f6d 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt @@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.* class ReplyController( private val replyService: ReplyService, ) { - @GetMapping("/api/v1/user/me/reply") + @GetMapping("/api/v1/me/reply") fun getRepliesByUser( @AuthUser user: User ): ResponseEntity{ @@ -22,7 +22,6 @@ class ReplyController( @GetMapping("/api/v1/review/{reviewId}/reply") fun getReplies( @PathVariable reviewId: String, - @AuthUser user: User, ): ResponseEntity{ val replies = replyService.getReplies(reviewId) return ResponseEntity.ok(replies) diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt index 9474c91..7fc3d2c 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt @@ -25,7 +25,6 @@ class ReviewController( @GetMapping("/api/v1/performance/{performanceId}/review") fun getReviews( @PathVariable performanceId: String, - @AuthUser user: User, ): ResponseEntity{ val reviews = reviewService.getReviews(performanceId) return ResponseEntity.ok(reviews) diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeEntity.kt deleted file mode 100644 index ad0d61c..0000000 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeEntity.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.wafflestudio.interpark.review.persistence - -import com.wafflestudio.interpark.user.persistence.UserEntity -import jakarta.persistence.* -import java.time.Instant - -@Entity(name = "reply_like") -@Table( - name = "reply_like", - uniqueConstraints = [UniqueConstraint(columnNames = ["reply_id", "user_id"])], - indexes = [ - Index(name = "idx_reply_user", columnList = "reply_id, user_id"), - ], -) -class ReplyLikeEntity( - @Id - @GeneratedValue(strategy = GenerationType.UUID) - val id: String? = null, - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "reply_id", nullable = false) - var reply: ReplyEntity, - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - var user: UserEntity, -) diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeRepository.kt deleted file mode 100644 index bbed3f0..0000000 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeRepository.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.wafflestudio.interpark.review.persistence - -import com.wafflestudio.interpark.user.persistence.UserEntity - -import jakarta.persistence.LockModeType - -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Lock - -interface ReplyLikeRepository : JpaRepository { - fun countByReply(reply: ReplyEntity): Int - - @Lock(LockModeType.PESSIMISTIC_WRITE) - fun findByReplyAndUser( - reply: ReplyEntity, - user: UserEntity, - ): ReplyLikeEntity? -} diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt index 3196931..befd9a5 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt @@ -1,9 +1,12 @@ package com.wafflestudio.interpark.review.persistence import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query interface ReplyRepository : JpaRepository { + @Query("SELECT r FROM ReplyEntity r WHERE r.review.id = :reviewId ORDER BY r.createdAt DESC") fun findByReviewId(reviewId: String): List + @Query("SELECT r FROM ReplyEntity r WHERE r.author.id = :authorId ORDER BY r.createdAt DESC") fun findByAuthorId(authorId: String): List fun countByReviewId(reviewId: String): Int } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt index 3c0855e..52720c0 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt @@ -6,7 +6,9 @@ import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Query interface ReviewRepository : JpaRepository { + @Query("SELECT r FROM ReviewEntity r WHERE r.performance.id = :performanceId ORDER BY r.createdAt DESC") fun findByPerformanceId(performanceId: String): List + @Query("SELECT r FROM ReviewEntity r WHERE r.author.id = :authorId ORDER BY r.createdAt DESC") fun findByAuthorId(authorId: String): List @Lock(LockModeType.PESSIMISTIC_WRITE) diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt index 625afd1..047a892 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt @@ -386,4 +386,94 @@ class ReplyIntegrationTest assert(targetReview!!.get("replyCount").asText() == "0") { "expected 0 replyCount but ${targetReview!!.get("likeCount").asText()}"} } + + @Test + fun `GET을 했을 때 댓글을 최신순으로 정렬되어 반환된다`() { + val otherAccessTokens = (1..5).map { num -> + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "otherMan2$num", + "password" to "goodPassword", + "nickname" to "NICKNAME", + "phoneNumber" to "010-1234-5678", + "email" to "hacker@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "otherMan2$num", + "password" to "goodPassword", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + } + (1..5).forEach { + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer ${otherAccessTokens[it-1]}") + .content( + mapper.writeValueAsString( + mapOf("content" to "$it"), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + } + + (1..5).forEach { + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "$it"), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + } + + val reviewReplyContent = mvc.perform( + get("/api/v1/review/$reviewId/reply") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + .map { it.get("content").asText() } + assert (reviewReplyContent == listOf("5","4","3","2","1","5","4","3","2","1")) { + "expected rating ${listOf("5","4","3","2","1","5","4","3","2","1")} but $reviewReplyContent" + } + + val userReplyContent = mvc.perform( + get("/api/v1/me/reply") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + .map { it.get("content").asText() } + assert (userReplyContent == (5 downTo 1).map {"$it"}) { + "expected rating ${(5 downTo 1).map {"$it"}} but $userReplyContent" + } + } } diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt index 31147bf..e6d04c5 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt @@ -108,7 +108,6 @@ class ReviewIntegrationTest // 4️⃣ 리뷰 조회 (성공) mvc.perform( get("/api/v1/performance/$performanceId/review") - .header("Authorization", "Bearer $accessToken"), ).andExpect(status().`is`(200)) .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) // reviewId가 포함된 객체가 존재하는지 확인 @@ -251,4 +250,103 @@ class ReviewIntegrationTest ).andExpect(status().`is`(401)) .andExpect(jsonPath("$.error").value("Unauthorized Access To Review")) } + + @Test + fun `GET을 했을 때 리뷰를 최신순으로 정렬하여 반환된다`() { + val otherAccessTokens = (1..5).map { num -> + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "otherMan$num", + "password" to "goodPassword", + "nickname" to "NICKNAME", + "phoneNumber" to "010-1234-5678", + "email" to "hacker@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "otherMan$num", + "password" to "goodPassword", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + } + + (1..5).forEach { + mvc.perform( + post("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer ${otherAccessTokens[it-1]}") + .content( + mapper.writeValueAsString( + mapOf( + "rating" to it, + "title" to "Great Performance!", + "content" to "Absolutely amazing. Highly recommend!", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + } + + (1..5).forEach { + mvc.perform( + post("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer ${accessToken}") + .content( + mapper.writeValueAsString( + mapOf( + "rating" to it, + "title" to "Great Performance!", + "content" to "Absolutely amazing. Highly recommend!", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + } + + val performanceReviewRating = mvc.perform( + get("/api/v1/performance/$performanceId/review") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + .map { it.get("rating").asInt() } + assert (performanceReviewRating == listOf(5,4,3,2,1,5,4,3,2,1)) { + "expected rating ${(5 downTo 1).toList()} but $performanceReviewRating" + } + + val userReviewRating = mvc.perform( + get("/api/v1/me/review") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + .map { it.get("rating").asInt() } + assert (userReviewRating == (5 downTo 1).toList()) { + "expected rating ${(5 downTo 1).toList()} but $userReviewRating" + } + } } From 469df9c12664694a653d182ec80c4b687e17eaa8 Mon Sep 17 00:00:00 2001 From: DoHyeon Kim Date: Wed, 22 Jan 2025 18:43:19 +0900 Subject: [PATCH 075/162] =?UTF-8?q?Spring=20security=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * merge test - generate performancehallentity.kt * feat: searchPerformance * chore: 도커 컴포즈 dockerfile 작성해서 도커 이미지 잘 만들어지고 http 파일로 테스트까지 가능한 상태입니다 * modify searchPerformance * add: docker-compose * modify build.gradle.kts * add wildcard import in UserService.kt * change Performance attribute 'genre' to 'category' * change category to ENUM Type * add some Init Datas * add some Init Datas * assign posterimageURL * add: Entity 엔티티 작업중 * assign posterimageURL * add: SeatService 임시저장하겠습니다 * feat: SeatService 좌석 확인, 예매, 예매 취소 기능 구현 아직 동시성 처리 안됐습니다 * feat: Add Controller 컨트롤러 추가했습니다 곧 테스트 코드 추가해보겠습니다 * modify searchPerformance, GET요청은 RequestBody를 사용할 수 없다고 하여 다시 RequestParam을 사용하는 원래 방식으로 변경 * feat: SeatCreation performanceHallId와 type을 받아 좌석엔티티들을 자동으로 생성하는 서비스 함수와 performanceEventId를 받아 빈 예약 가능 엔티티들을 자동을 생성하는 서비스를 만들었습니다 추후 createPerformanceHall과 createPerformanceEvent함수를 수정해서 과정을 더 자동화할 수 있을 것 같습니다 * add: SeatIntegrationTest SeatIntegrationTest 만들었습니다 테스트 전부 통과하며 빌드 성공합니다 다만 테스트에 mysql 데이터베이스가 쓰이며 초기화가 자동으로 되지 않아 문제가 생기니 이는 개선이 필요합니다 * chore: use h2 while test application.yaml 파일을 테스트 코드를 위해 하나 추가해줬습니다 테스트코드에서 @Transactional까지 사용한다면 테스트 코드가 서로 영향을 끼치지 않게 됩니다 * fix: Seat Reservation reservation 바뀐 정보 반영하도록 save 사용했고, Detail확인하는 영역은 컨트롤러까지 분리했습니다 * style: seat ktlint seat폴더의 파일들에 ktlint 적용 * modify MakeFile * 공연 검색 기능 구현 * fromEntity parameter change: receive Entities -> receive DTOs * modify searchPerformance endpoint URL * chore: makefile OS 따라서 다르게 작동하게 수정 * 공연 1개 상세 정보 반환 * minor modify: change how null type is handled * 초기 데이터 수정, 공연이벤트는 일단 공연 기간의 첫날과 마지막날만 생성하였음 * modify DataInitializer, SeatIntegrationTest * add PerformanceDetail Uri * make PerformanceItegrationTest.kt * feat: 예매 동시성 동시성 처리 완료 동시성 처리는 잘 되지만, 테스트 코드 작성이 많이 까다로움 * feat: Get My Reservation Api 유저가 로그인한채로 본인의 예약 목록을 확인할 수 있는 기능 추가 * feat: Seat Get TestCode Get API가 정상작동하는지 테스트코드 추가 * modify SearchPerformanceResponse * check auth for create, delete performance * check auth for create, delete performanceEvent, performanceHall * add posterUri attr to BriefPerformanceDetail * minor change * feat: 예매목록 Brief로 주기 기존에 id만 주던 것을 Brief 데이터의 리스트를 주는 것으로 수정 signin을 할 때 User도 같이 반환하도록 수정 * fix: 로그아웃, 토큰 재발급 버그 수정, 엔드포인트 수정 * add UserIdentityNotFoundException * modify PerformanceDurtion * backup package to * modify endpoint * applying spring security * setup .env * fix .http localhost port number * apply spring security * apply spring security * minor * remove .env from tracked files * gitignore * remove unused method * minor * minor * add new line at EOF * modify jwtfilter comment * convert Instant to KoreanLocalDateTime in PerformanceEventDTO * add permission --------- Co-authored-by: ChungPlusPlus --- .gitignore | 5 +- .../controller/PerformanceController.kt | 2 +- archive/user_old/AuthUser.kt | 5 + archive/user_old/UserAccessTokenUtil.kt | 86 ++++++++ archive/user_old/UserArgumentResolver.kt | 42 ++++ archive/user_old/UserException.kt | 66 +++++++ archive/user_old/controller/User.kt | 23 +++ archive/user_old/controller/UserController.kt | 186 ++++++++++++++++++ .../persistence/RefreshTokenEntity.kt | 21 ++ .../persistence/RefreshTokenRepository.kt | 9 + archive/user_old/persistence/UserEntity.kt | 24 +++ .../persistence/UserIdentityEntity.kt | 31 +++ .../persistence/UserIdentityRepository.kt | 8 + .../user_old/persistence/UserRepository.kt | 9 + archive/user_old/service/UserService.kt | 95 +++++++++ build.gradle.kts | 2 +- .../interpark/config/DataInitializer.kt | 5 +- .../interpark/config/SwaggerConfig.kt | 62 ++++++ .../controller/PerformanceController.kt | 29 +-- .../controller/PerformanceEvent.kt | 16 +- .../controller/PerformanceEventController.kt | 37 +--- .../controller/PerformanceHallController.kt | 29 +-- .../service/PerformanceEventService.kt | 8 +- .../review/controller/ReplyController.kt | 22 +-- .../review/controller/ReviewController.kt | 28 ++- .../interpark/review/service/ReplyService.kt | 21 +- .../interpark/review/service/ReviewService.kt | 17 +- .../seat/controller/SeatController.kt | 18 +- .../interpark/seat/service/SeatService.kt | 19 +- .../security/JwtAuthenticationFilter.kt | 53 +++++ .../security/RestAccessDeniedHandler.kt | 29 +++ .../interpark/security/SecurityConfig.kt | 59 ++++++ .../user/controller/UserController.kt | 17 +- .../user/controller/UserDetailsImpl.kt | 35 ++++ .../user/persistence/UserIdentityEntity.kt | 11 +- .../persistence/UserIdentityRepository.kt | 1 + .../user/service/UserDetailsServiceImpl.kt | 28 +++ .../interpark/user/service/UserService.kt | 5 +- .../interpark/UserIntegrationTest.kt | 63 +++++- src/test/resources/SeatApi.http | 14 +- src/test/resources/UserApi.http | 27 ++- 41 files changed, 1077 insertions(+), 190 deletions(-) create mode 100644 archive/user_old/AuthUser.kt create mode 100644 archive/user_old/UserAccessTokenUtil.kt create mode 100644 archive/user_old/UserArgumentResolver.kt create mode 100644 archive/user_old/UserException.kt create mode 100644 archive/user_old/controller/User.kt create mode 100644 archive/user_old/controller/UserController.kt create mode 100644 archive/user_old/persistence/RefreshTokenEntity.kt create mode 100644 archive/user_old/persistence/RefreshTokenRepository.kt create mode 100644 archive/user_old/persistence/UserEntity.kt create mode 100644 archive/user_old/persistence/UserIdentityEntity.kt create mode 100644 archive/user_old/persistence/UserIdentityRepository.kt create mode 100644 archive/user_old/persistence/UserRepository.kt create mode 100644 archive/user_old/service/UserService.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/config/SwaggerConfig.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/security/RestAccessDeniedHandler.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt diff --git a/.gitignore b/.gitignore index d849736..7159cd5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ out/ !**/src/main/**/out/ !**/src/test/**/out/ +# .env +.env + ### Kotlin ### .kotlin @@ -43,5 +46,3 @@ bin/ ### Mac OS ### .DS_Store - -.env \ No newline at end of file diff --git a/archive/performance_old/controller/PerformanceController.kt b/archive/performance_old/controller/PerformanceController.kt index 41bd506..b4eacae 100644 --- a/archive/performance_old/controller/PerformanceController.kt +++ b/archive/performance_old/controller/PerformanceController.kt @@ -10,7 +10,7 @@ import org.springframework.web.bind.annotation.RestController class PerformanceController( private val performanceService: PerformanceService, ) { - @GetMapping("v1/performance/search") + @GetMapping("api/v1/performance/search") fun searchPerformance( @RequestParam title: String?, @RequestParam genre: String?, diff --git a/archive/user_old/AuthUser.kt b/archive/user_old/AuthUser.kt new file mode 100644 index 0000000..58bd34a --- /dev/null +++ b/archive/user_old/AuthUser.kt @@ -0,0 +1,5 @@ +package com.wafflestudio.interpark.user + +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class AuthUser diff --git a/archive/user_old/UserAccessTokenUtil.kt b/archive/user_old/UserAccessTokenUtil.kt new file mode 100644 index 0000000..7804127 --- /dev/null +++ b/archive/user_old/UserAccessTokenUtil.kt @@ -0,0 +1,86 @@ +package com.wafflestudio.interpark.user + +import com.wafflestudio.interpark.user.persistence.RefreshTokenEntity +import com.wafflestudio.interpark.user.persistence.RefreshTokenRepository +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.springframework.stereotype.Component +import java.nio.charset.StandardCharsets +import java.util.* + +@Component +class UserAccessTokenUtil( + private var refreshTokenRepository: RefreshTokenRepository, +) { + fun generateAccessToken(username: String): String { + val now = Date() + val expiryDate = Date(now.time + ACCESS_EXPIRATION_TIME) + return Jwts.builder() + .signWith(SECRET_KEY) + .setSubject(username) + .setIssuedAt(now) + .setExpiration(expiryDate) + .compact() + } + + fun validateAccessToken(accessToken: String): String? { + return try { + val claims = + Jwts.parserBuilder() + .setSigningKey(SECRET_KEY) + .build() + .parseClaimsJws(accessToken) + .body + if (claims.expiration < Date()) { + throw TokenExpiredException() + } + return claims.subject + } catch (e: Exception) { + null + } + } + + fun generateRefreshToken(userId: String): String { + val now = Date() + val expiryDate = Date(now.time + REFRESH_EXPIRATION_TIME) + val refreshToken = UUID.randomUUID().toString() + + // 해당 유저의 다른 refreshToken 이 있다면 삭제 + val existingToken = refreshTokenRepository.findByUserId(userId) + if (existingToken != null) { + refreshTokenRepository.delete(existingToken) + } + + refreshTokenRepository.save( + RefreshTokenEntity( + userId = userId, + refreshToken = refreshToken, + expiryDate = expiryDate, + ), + ) + return refreshToken + } + + fun refreshAccessToken(refreshToken: String): Pair? { + val storedRefreshToken = refreshTokenRepository.findByRefreshToken(refreshToken) ?: return null + + if (storedRefreshToken.expiryDate < Date()) throw TokenExpiredException() + + val newAccessToken = generateAccessToken(storedRefreshToken.userId) + val newRefreshToken = generateRefreshToken(storedRefreshToken.userId) + + return Pair(newAccessToken, newRefreshToken) + } + + fun removeRefreshToken(refreshToken: String) { + val storedRefreshToken = refreshTokenRepository.findByRefreshToken(refreshToken) ?: return + refreshTokenRepository.delete(storedRefreshToken) + } + + companion object { + private const val ACCESS_EXPIRATION_TIME = 1000 * 60 * 15 // 15 minutes + private const val REFRESH_EXPIRATION_TIME = 1000 * 60 * 60 * 24 // 1 day + private val SECRET_KEY = Keys.hmacShaKeyFor("THISSHOULDBEPROTECTEDASDFASDFASDFASDFASDFASDF".toByteArray(StandardCharsets.UTF_8)) + // TODO("비밀키 숨겨야 한다") + } +} diff --git a/archive/user_old/UserArgumentResolver.kt b/archive/user_old/UserArgumentResolver.kt new file mode 100644 index 0000000..7d81f35 --- /dev/null +++ b/archive/user_old/UserArgumentResolver.kt @@ -0,0 +1,42 @@ +package com.wafflestudio.interpark.user + +import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.service.UserService +import org.springframework.core.MethodParameter +import org.springframework.stereotype.Component +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +@Component +class UserArgumentResolver( + private val userService: UserService, +) : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean { + return parameter.parameterType == User::class.java + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): User? { + return runCatching { + val accessToken = + requireNotNull( + webRequest.getHeader("Authorization")?.split(" ")?.let { + if (it.getOrNull(0) == "Bearer") it.getOrNull(1) else null + }, + ) + userService.authenticate(accessToken) + }.getOrElse { + if (parameter.hasParameterAnnotation(AuthUser::class.java)) { + throw AuthenticateException() + } else { + null + } + } + } +} diff --git a/archive/user_old/UserException.kt b/archive/user_old/UserException.kt new file mode 100644 index 0000000..f9ab9b1 --- /dev/null +++ b/archive/user_old/UserException.kt @@ -0,0 +1,66 @@ +package com.wafflestudio.interpark.user + +import com.wafflestudio.interpark.DomainException +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatusCode + +sealed class UserException( + errorCode: Int, + httpStatusCode: HttpStatusCode, + msg: String, + cause: Throwable? = null, +) : DomainException(errorCode, httpStatusCode, msg, cause) + +class SignUpBadUsernameException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "Bad Username", +) + +class SignUpBadPasswordException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "Bad Password", +) + +class SignUpUsernameConflictException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.CONFLICT, + msg = "Username Conflict", +) + +class SignInUserNotFoundException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.UNAUTHORIZED, + msg = "User not found", +) + +class SignInInvalidPasswordException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.UNAUTHORIZED, + msg = "Invalid Password", +) + +class UserIdentityNotFoundException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.NOT_FOUND, + msg = "UserIdentity not found", +) + +class AuthenticateException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.UNAUTHORIZED, + msg = "Unauthorized", +) + +class TokenExpiredException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.UNAUTHORIZED, + msg = "Token Expired", +) + +class TokenNotFoundException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.UNAUTHORIZED, + msg = "Token not found", +) diff --git a/archive/user_old/controller/User.kt b/archive/user_old/controller/User.kt new file mode 100644 index 0000000..cb9f1e1 --- /dev/null +++ b/archive/user_old/controller/User.kt @@ -0,0 +1,23 @@ +package com.wafflestudio.interpark.user.controller + +import com.wafflestudio.interpark.user.persistence.UserEntity + +data class User( + val id: String, + val username: String, + val nickname: String, + val phoneNumber: String, + val email: String, +) { + companion object { + fun fromEntity(entity: UserEntity): User { + return User( + id = entity.id!!, + username = entity.username, + nickname = entity.nickname, + phoneNumber = entity.phoneNumber, + email = entity.email, + ) + } + } +} diff --git a/archive/user_old/controller/UserController.kt b/archive/user_old/controller/UserController.kt new file mode 100644 index 0000000..a388b1d --- /dev/null +++ b/archive/user_old/controller/UserController.kt @@ -0,0 +1,186 @@ +package com.wafflestudio.interpark.user.controller + +import com.wafflestudio.interpark.user.* +import com.wafflestudio.interpark.user.persistence.UserRole +import com.wafflestudio.interpark.user.service.UserService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.servlet.http.Cookie +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +class UserController( + private val userService: UserService, +) { + @GetMapping("/api/v1/ping") + @Operation( + summary = "핑퐁 테스트", + description = "\"ping\"을 보내면 \"pong\"을 반환합니다." + ) + fun ping() : ResponseEntity> { + return ResponseEntity.ok(mapOf("message" to "pong")) + } + + @PostMapping("/api/v1/signup") + @Operation( + summary = "사용자 회원가입", + description = """ + 새로운 사용자를 등록합니다. + 사용자 이름, 비밀번호, 닉네임, 이메일, 전화번호를 입력받아 저장합니다. + 요청이 유효하지 않은 경우 또는 사용자 이름이 중복된 경우 적절한 에러 메시지를 반환합니다. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "회원가입 성공", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = SignUpResponse::class) + )] + ), + ApiResponse( + responseCode = "400", + description = "잘못된 요청 (사용자 이름 또는 비밀번호가 유효하지 않음)", + content = [Content( + mediaType = "application/json", + schema = Schema( + type = "object", + example = """ + { + "error": "비밀번호는 8~12자여야 합니다.", + "errorCode": "INVALID_PASSWORD" + } + """ + ) + )] + ), + ApiResponse( + responseCode = "409", + description = "중복된 사용자 이름 (사용자 이름이 이미 존재함)", + content = [Content( + mediaType = "application/json", + schema = Schema( + type = "object", + example = """ + { + "error": "사용자 이름이 이미 존재합니다.", + "errorCode": "USERNAME_CONFLICT" + } + """ + ) + )] + ) + ], + requestBody = io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "회원가입 요청 데이터", + required = true, + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = SignUpRequest::class) + )] + ) + ) + fun signup( + @RequestBody request: SignUpRequest, + ): ResponseEntity { + val user = + userService.signUp( + username = request.username, + password = request.password, + nickname = request.nickname, + email = request.email, + phoneNumber = request.phoneNumber, + role = request.role, + ) + return ResponseEntity.ok(SignUpResponse(user)) + } + + @PostMapping("/api/v1/signin") + fun signin( + @RequestBody request: SignInRequest, + response: HttpServletResponse, + ): ResponseEntity { + val (accessToken, refreshToken) = userService.signIn(request.username, request.password) + val cookie = + Cookie("refreshToken", refreshToken).apply { + isHttpOnly = true + secure = true + path = "/api/v1/refresh_token" + maxAge = 60 * 60 * 24 * 7 + // TODO("domain 설정하기") + } + response.addCookie(cookie) + + return ResponseEntity.ok(TokenResponse(accessToken)) + } + + @GetMapping("/api/v1/users/me") + fun me( + @AuthUser user: User, + ): ResponseEntity { + return ResponseEntity.ok(user) + } + + @PostMapping("/api/v1/signout") + fun signout( + @CookieValue(value = "refresh_token", required = false) refreshToken: String?, + ): ResponseEntity { + if (refreshToken == null) { + throw TokenNotFoundException() + } + userService.signOut(refreshToken) + return ResponseEntity.noContent().build() + } + + @PostMapping("/api/v1/refresh_token") + fun refreshToken( + @CookieValue(value = "refreshToken", required = false) refreshToken: String?, + response: HttpServletResponse, + ): ResponseEntity { + if (refreshToken == null) { + throw TokenNotFoundException() + } + + val (newAccessToken, newRefreshToken) = userService.refreshAccessToken(refreshToken) + + val cookie = + Cookie("refreshToken", newRefreshToken).apply { + isHttpOnly = true + secure = true + path = "/api/v1/refresh_token" + maxAge = 60 * 60 * 24 * 7 + // TODO("domain 설정하기") + } + response.addCookie(cookie) + + return ResponseEntity.ok(TokenResponse(newAccessToken)) + } +} + +data class SignUpRequest( + val username: String, + val password: String, + val nickname: String, + val phoneNumber: String, + val email: String, + val role: UserRole = UserRole.USER, +) + +data class SignUpResponse(val user: User) + +data class SignInRequest( + val username: String, + val password: String, +) + +data class TokenResponse( + val accessToken: String, +) + +data class SignOutRequest(val refreshToken: String) + +data class RefreshTokenRequest(val refreshToken: String) diff --git a/archive/user_old/persistence/RefreshTokenEntity.kt b/archive/user_old/persistence/RefreshTokenEntity.kt new file mode 100644 index 0000000..ab253d1 --- /dev/null +++ b/archive/user_old/persistence/RefreshTokenEntity.kt @@ -0,0 +1,21 @@ +package com.wafflestudio.interpark.user.persistence + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import java.util.* + +@Entity +class RefreshTokenEntity( + @Id + @GeneratedValue(strategy = GenerationType.UUID) + val id: String? = null, + @Column(name = "user_id", nullable = false) + val userId: String, + @Column(name = "refresh_token", nullable = false, unique = true) + val refreshToken: String, + @Column(name = "expiry_date", nullable = false) + val expiryDate: Date, +) diff --git a/archive/user_old/persistence/RefreshTokenRepository.kt b/archive/user_old/persistence/RefreshTokenRepository.kt new file mode 100644 index 0000000..88e3ca8 --- /dev/null +++ b/archive/user_old/persistence/RefreshTokenRepository.kt @@ -0,0 +1,9 @@ +package com.wafflestudio.interpark.user.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface RefreshTokenRepository : JpaRepository { + fun findByRefreshToken(refreshToken: String): RefreshTokenEntity? + + fun findByUserId(userId: String): RefreshTokenEntity? +} diff --git a/archive/user_old/persistence/UserEntity.kt b/archive/user_old/persistence/UserEntity.kt new file mode 100644 index 0000000..9ddccb0 --- /dev/null +++ b/archive/user_old/persistence/UserEntity.kt @@ -0,0 +1,24 @@ +package com.wafflestudio.interpark.user.persistence + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id + +@Entity +class UserEntity( + @Id + @GeneratedValue(strategy = GenerationType.UUID) + val id: String? = null, + @Column(name = "username", nullable = false) + val username: String, + @Column(name = "nickname", nullable = false) + val nickname: String, + @Column(name = "phone_number", nullable = false) + val phoneNumber: String, + @Column(name = "email", nullable = false) + val email: String, + @Column(name = "address", nullable = true) + val address: String? = null, +) diff --git a/archive/user_old/persistence/UserIdentityEntity.kt b/archive/user_old/persistence/UserIdentityEntity.kt new file mode 100644 index 0000000..f6c15e2 --- /dev/null +++ b/archive/user_old/persistence/UserIdentityEntity.kt @@ -0,0 +1,31 @@ +package com.wafflestudio.interpark.user.persistence + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.OneToOne + +@Entity +class UserIdentityEntity( + @Id + @GeneratedValue(strategy = GenerationType.UUID) + val id: String? = null, + @OneToOne + @JoinColumn(name = "user_id") + var user: UserEntity, + @Column(name = "role", nullable = false) + var role: UserRole = UserRole.USER, + @Column(name = "hashed_password", nullable = false) + val hashedPassword: String, + @Column(name = "provider", nullable = false) + val provider: String, + @Column(name = "social_id", nullable = true) + val socialId: String? = null, +) + +enum class UserRole { + USER, ADMIN +} \ No newline at end of file diff --git a/archive/user_old/persistence/UserIdentityRepository.kt b/archive/user_old/persistence/UserIdentityRepository.kt new file mode 100644 index 0000000..628636f --- /dev/null +++ b/archive/user_old/persistence/UserIdentityRepository.kt @@ -0,0 +1,8 @@ +package com.wafflestudio.interpark.user.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface UserIdentityRepository : JpaRepository { + fun findByUser(user: UserEntity): UserIdentityEntity? + fun findByUserId(userId: String): UserIdentityEntity? +} diff --git a/archive/user_old/persistence/UserRepository.kt b/archive/user_old/persistence/UserRepository.kt new file mode 100644 index 0000000..b010531 --- /dev/null +++ b/archive/user_old/persistence/UserRepository.kt @@ -0,0 +1,9 @@ +package com.wafflestudio.interpark.user.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface UserRepository : JpaRepository { + fun findByUsername(username: String): UserEntity? + + fun existsByUsername(username: String): Boolean +} diff --git a/archive/user_old/service/UserService.kt b/archive/user_old/service/UserService.kt new file mode 100644 index 0000000..fd774c5 --- /dev/null +++ b/archive/user_old/service/UserService.kt @@ -0,0 +1,95 @@ +package com.wafflestudio.interpark.user.service + +import com.wafflestudio.interpark.user.* +import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.persistence.UserEntity +import com.wafflestudio.interpark.user.persistence.UserIdentityEntity +import com.wafflestudio.interpark.user.persistence.UserIdentityRepository +import com.wafflestudio.interpark.user.persistence.UserRepository +import com.wafflestudio.interpark.user.persistence.UserRole +import org.mindrot.jbcrypt.BCrypt +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class UserService( + private val userRepository: UserRepository, + private val userIdentityRepository: UserIdentityRepository, + private val userAccessTokenUtil: UserAccessTokenUtil, +) { + @Transactional + fun signUp( + username: String, + password: String, + nickname: String, + phoneNumber: String, + email: String, + role: UserRole = UserRole.USER, + ): User { + if (username.length < 6 || username.length > 20) { + throw SignUpBadUsernameException() + } + if (password.length < 8 || password.length > 12) { + throw SignUpBadPasswordException() + } + if (userRepository.existsByUsername(username)) { + throw SignUpUsernameConflictException() + } + val encryptedPassword = BCrypt.hashpw(password, BCrypt.gensalt()) + val user = + userRepository.save( + UserEntity( + username = username, + nickname = nickname, + phoneNumber = phoneNumber, + email = email, + ), + ) + userIdentityRepository.save( + UserIdentityEntity( + user = user, + role = role, + hashedPassword = encryptedPassword, + provider = "self", + ), + ) + return User.fromEntity(user) + } + + @Transactional + fun signIn( + username: String, + password: String, + ): Pair { + val targetUser = userRepository.findByUsername(username) ?: throw SignInUserNotFoundException() + val targetIdentity = userIdentityRepository.findByUser(targetUser) ?: throw SignInUserNotFoundException() + if (!BCrypt.checkpw(password, targetIdentity.hashedPassword)) { + throw SignInInvalidPasswordException() + } + val accessToken = userAccessTokenUtil.generateAccessToken(targetUser.id!!) + val refreshToken = userAccessTokenUtil.generateRefreshToken(targetIdentity.id!!) + return Pair(accessToken, refreshToken) + } + + @Transactional + fun signOut(refreshToken: String) { + userAccessTokenUtil.removeRefreshToken(refreshToken) + } + + @Transactional + fun authenticate(accessToken: String): User { + val userId = userAccessTokenUtil.validateAccessToken(accessToken) ?: throw AuthenticateException() + val user = userRepository.findByIdOrNull(userId) ?: throw AuthenticateException() + return User.fromEntity(user) + } + + @Transactional + fun refreshAccessToken(refreshToken: String): Pair { + return userAccessTokenUtil.refreshAccessToken(refreshToken) ?: throw AuthenticateException() + } + + fun getUserIdentityByUserId(userId: String): UserIdentityEntity? { + return userIdentityRepository.findByUserId(userId) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 231e961..7afc0ba 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,7 +21,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-validation") - //implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-security") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.mindrot:jbcrypt:0.4") diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index cf74dd0..004f0e6 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -10,6 +10,7 @@ import com.wafflestudio.interpark.performance.service.PerformanceHallService import com.wafflestudio.interpark.performance.service.PerformanceService import org.springframework.boot.CommandLineRunner import org.springframework.context.annotation.Configuration +import java.time.LocalDateTime @Configuration class DataInitializer( @@ -270,8 +271,8 @@ class DataInitializer( performanceEventService.createPerformanceEvent( performanceId = performance.id!!, performanceHallId = hall.id!!, - startAt = startAt, - endAt = endAt + startAt = LocalDateTime.parse(startAt), + endAt = LocalDateTime.parse(endAt) ) } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/SwaggerConfig.kt b/src/main/kotlin/com/wafflestudio/interpark/config/SwaggerConfig.kt new file mode 100644 index 0000000..9813b0a --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/config/SwaggerConfig.kt @@ -0,0 +1,62 @@ +package com.wafflestudio.interpark.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.security.OAuthFlow +import io.swagger.v3.oas.models.security.OAuthFlows +import io.swagger.v3.oas.models.security.Scopes +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 +class SwaggerConfig { + + @Bean + fun openAPI(): OpenAPI { + return OpenAPI() + .info( + Info() + .title("Interpark Ticket API") + .version("v1") + .description( + """ + API documentation for Interpark Ticket application. + - Supports JWT-based local authentication. + - Supports OAuth2.0 for social login (Google, Naver). + """.trimIndent() + ) + ) + .addSecurityItem(SecurityRequirement().addList("Bearer Authentication")) + .addSecurityItem(SecurityRequirement().addList("Google OAuth2")) + .components( + Components() + .addSecuritySchemes( + "Bearer Authentication", + SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + ) + .addSecuritySchemes( + "Google OAuth2", + SecurityScheme() + .type(SecurityScheme.Type.OAUTH2) + .flows( + OAuthFlows() + .authorizationCode( + OAuthFlow() + .authorizationUrl("https://accounts.google.com/o/oauth2/auth") + .tokenUrl("https://oauth2.googleapis.com/token") + .scopes( + Scopes() + .addString("email", "email access") + ) + ) + ) + ) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index 8ff1a10..f5867d2 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -3,23 +3,18 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import io.swagger.v3.oas.annotations.Operation import com.wafflestudio.interpark.performance.service.PerformanceService -import com.wafflestudio.interpark.user.AuthUser -import com.wafflestudio.interpark.user.UserIdentityNotFoundException -import com.wafflestudio.interpark.user.controller.User -import com.wafflestudio.interpark.user.persistence.UserRole -import com.wafflestudio.interpark.user.service.UserService +import com.wafflestudio.interpark.user.controller.UserDetailsImpl import jakarta.validation.Valid import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* -import java.time.LocalDate @RestController class PerformanceController( private val performanceService: PerformanceService, - private val userService: UserService, ) { @GetMapping("/api/v1/performance/search") @Operation( @@ -39,16 +34,8 @@ class PerformanceController( @PostMapping("/admin/v1/performance") fun createPerformance( @Valid @RequestBody request: CreatePerformanceRequest, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl, ): ResponseEntity { - // UserIdentity를 통해 역할(Role) 확인 - val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 - ?: throw UserIdentityNotFoundException() - - if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) - } - val newPerformance: Performance = performanceService .createPerformance( @@ -76,16 +63,8 @@ class PerformanceController( @DeleteMapping("/admin/v1/performance/{performanceId}") fun deletePerformance( @PathVariable performanceId: String, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl, ): ResponseEntity { - // UserIdentity를 통해 역할(Role) 확인 - val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 - ?: throw UserIdentityNotFoundException() - - if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) - } - performanceService.deletePerformance(performanceId) return ResponseEntity.noContent().build() } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEvent.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEvent.kt index a90508b..c4ab750 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEvent.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEvent.kt @@ -2,24 +2,30 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.persistence.PerformanceEventEntity import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId data class PerformanceEvent( val id: String, val performanceId: String, val performanceHallId: String, - val startAt: Instant, - val endAt: Instant, + val startAt: LocalDateTime, + val endAt: LocalDateTime, ) { companion object { fun fromEntity(entity: PerformanceEventEntity): PerformanceEvent { return PerformanceEvent( - id = entity.id!!, + id = entity.id, performanceId = entity.performance.id!!, performanceHallId = entity.performanceHall.id!!, - startAt = entity.startAt, - endAt = entity.endAt, + startAt = convertInstantToKoreanTime(entity.startAt), + endAt = convertInstantToKoreanTime(entity.endAt), ) } + + private fun convertInstantToKoreanTime(instant: Instant): LocalDateTime { + return LocalDateTime.ofInstant(instant, ZoneId.of("Asia/Seoul")) + } } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt index 091ec45..92caa1a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt @@ -1,25 +1,20 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.service.PerformanceEventService -import com.wafflestudio.interpark.user.controller.User -import com.wafflestudio.interpark.user.AuthUser -import com.wafflestudio.interpark.user.UserIdentityNotFoundException -import com.wafflestudio.interpark.user.persistence.UserRole -import com.wafflestudio.interpark.user.service.UserService +import com.wafflestudio.interpark.user.controller.UserDetailsImpl import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* import java.time.LocalDate -import java.time.format.DateTimeFormatter +import java.time.LocalDateTime @RestController class PerformanceEventController( private val performanceEventService: PerformanceEventService, - private val userService: UserService ) { @GetMapping("/api/v1/performance-event") fun getPerformanceEvent( - @AuthUser user: User, ): ResponseEntity { // Currently, no search val performanceEventList: List = performanceEventService @@ -29,7 +24,6 @@ class PerformanceEventController( @GetMapping("/api/v1/performance-event/{performanceId}/{performanceDate}") fun getPerformanceEventFromDate( - @AuthUser user: User, @PathVariable performanceId: String, @PathVariable performanceDate: String, ): ResponseEntity { @@ -38,7 +32,6 @@ class PerformanceEventController( performanceId = performanceId, performanceDate = localPerformanceDate, ) - return ResponseEntity.ok(performanceEventList) } @@ -47,16 +40,8 @@ class PerformanceEventController( @PostMapping("/admin/v1/performance-event") fun createPerformanceEvent( @RequestBody request: CreatePerformanceEventRequest, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl, ): ResponseEntity { - // UserIdentity를 통해 역할(Role) 확인 - val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 - ?: throw UserIdentityNotFoundException() - - if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) - } - val newPerformanceEvent: PerformanceEvent = performanceEventService .createPerformanceEvent( @@ -71,16 +56,8 @@ class PerformanceEventController( @DeleteMapping("/admin/v1/performance-event/{performanceEventId}") fun deletePerformanceEvent( @PathVariable performanceEventId: String, - @AuthUser user: User + @AuthenticationPrincipal userDetails: UserDetailsImpl, ): ResponseEntity { - // UserIdentity를 통해 역할(Role) 확인 - val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 - ?: throw UserIdentityNotFoundException() - - if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) - } - performanceEventService.deletePerformanceEvent(performanceEventId) return ResponseEntity.noContent().build() } @@ -92,8 +69,8 @@ typealias GetPerformanceEventResponse = List data class CreatePerformanceEventRequest( val performanceId: String, val performanceHallId: String, - val startAt: String, - val endAt: String, + val startAt: LocalDateTime, + val endAt: LocalDateTime, ) typealias CreatePerformanceEventResponse = PerformanceEvent \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt index 4b0ca94..0c0eb6b 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt @@ -1,23 +1,18 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.service.PerformanceHallService -import com.wafflestudio.interpark.user.controller.User -import com.wafflestudio.interpark.user.AuthUser -import com.wafflestudio.interpark.user.UserIdentityNotFoundException -import com.wafflestudio.interpark.user.persistence.UserRole -import com.wafflestudio.interpark.user.service.UserService +import com.wafflestudio.interpark.user.controller.UserDetailsImpl import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* @RestController class PerformanceHallController( private val performanceHallService: PerformanceHallService, - private val userService: UserService ) { @GetMapping("/api/v1/performance-hall") fun getPerformanceHall( - @AuthUser user: User, ): ResponseEntity { // Currently, no search val performanceHallList: List = performanceHallService @@ -30,16 +25,8 @@ class PerformanceHallController( @PostMapping("/admin/v1/performance-hall") fun createPerformanceHall( @RequestBody request: CreatePerformanceHallRequest, - @AuthUser user: User + @AuthenticationPrincipal userDetails: UserDetailsImpl, ): ResponseEntity { - // UserIdentity를 통해 역할(Role) 확인 - val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 - ?: throw UserIdentityNotFoundException() - - if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) - } - val newPerformanceHall: PerformanceHall = performanceHallService .createPerformanceHall( @@ -53,16 +40,8 @@ class PerformanceHallController( @DeleteMapping("/admin/v1/performance-hall/{performanceHallId}") fun deletePerformance( @PathVariable performanceHallId: String, - @AuthUser user: User + @AuthenticationPrincipal userDetails: UserDetailsImpl, ): ResponseEntity { - // UserIdentity를 통해 역할(Role) 확인 - val userIdentity = userService.getUserIdentityByUserId(user.id) // user.id를 통해 UserIdentity 조회 - ?: throw UserIdentityNotFoundException() - - if (userIdentity.role != UserRole.ADMIN) { // 역할(Role)이 ADMIN이 아니면 FORBIDDEN 반환 - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null) - } - performanceHallService.deletePerformanceHall(performanceHallId) return ResponseEntity.noContent().build() } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt index 75c1ab4..deb6c11 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt @@ -43,16 +43,16 @@ class PerformanceEventService( .map { PerformanceEvent.fromEntity(it) }; } - fun parseKoreanTimeToInstant(koreanTime: String): Instant { + fun parseKoreanTimeToInstant(koreanTime: LocalDateTime): Instant { val koreanZone = ZoneId.of("Asia/Seoul") - return LocalDateTime.parse(koreanTime).atZone(koreanZone).toInstant() + return koreanTime.atZone(koreanZone).toInstant() } fun createPerformanceEvent( performanceId: String, performanceHallId: String, - startAt: String, - endAt: String, + startAt: LocalDateTime, + endAt: LocalDateTime, ): PerformanceEvent { val performanceEntity: PerformanceEntity = performanceRepository.findByIdOrNull(performanceId) ?: throw PerformanceNotFoundException() diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt index b810793..e78bcda 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt @@ -1,10 +1,9 @@ package com.wafflestudio.interpark.review.controller -import com.wafflestudio.interpark.review.* import com.wafflestudio.interpark.review.service.ReplyService -import com.wafflestudio.interpark.user.AuthUser -import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.controller.UserDetailsImpl import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* @RestController @@ -13,16 +12,15 @@ class ReplyController( ) { @GetMapping("/api/v1/user/me/reply") fun getRepliesByUser( - @AuthUser user: User + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity{ - val replies = replyService.getRepliesByUser(user) + val replies = replyService.getRepliesByUser(userDetails.getUserId()) return ResponseEntity.ok(replies) } @GetMapping("/api/v1/review/{reviewId}/reply") fun getReplies( @PathVariable reviewId: String, - @AuthUser user: User, ): ResponseEntity{ val replies = replyService.getReplies(reviewId) return ResponseEntity.ok(replies) @@ -32,9 +30,9 @@ class ReplyController( fun createReply( @RequestBody request: CreateReplyRequest, @PathVariable reviewId: String, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - val reply = replyService.createReply(user, reviewId, request.content) + val reply = replyService.createReply(userDetails.getUserId(), reviewId, request.content) return ResponseEntity.status(201).body(reply) } @@ -42,18 +40,18 @@ class ReplyController( fun editReply( @RequestBody request: EditReplyRequest, @PathVariable replyId: String, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - val reply = replyService.editReply(user, replyId, request.content) + val reply = replyService.editReply(userDetails.getUserId(), replyId, request.content) return ResponseEntity.ok(reply) } @DeleteMapping("/api/v1/reply/{replyId}") fun deleteReply( @PathVariable replyId: String, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - replyService.deleteReply(user, replyId) + replyService.deleteReply(userDetails.getUserId(), replyId) return ResponseEntity.noContent().build() } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt index fa15623..b20418a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt @@ -1,10 +1,9 @@ package com.wafflestudio.interpark.review.controller -import com.wafflestudio.interpark.review.* import com.wafflestudio.interpark.review.service.ReviewService -import com.wafflestudio.interpark.user.AuthUser -import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.controller.UserDetailsImpl import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* @RestController @@ -14,28 +13,27 @@ class ReviewController( @GetMapping("/api/v1/me/review") fun getMyReviews( - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity{ - val reviews = reviewService.getReviewsByUser(user); - return ResponseEntity.ok(reviews); + val reviews = reviewService.getReviewsByUser(userDetails.getUserId()) + return ResponseEntity.ok(reviews) } @GetMapping("/api/v1/performance/{performanceId}/review") fun getReviews( @PathVariable performanceId: String, - @AuthUser user: User, ): ResponseEntity{ - val reveiws = reviewService.getReviews(performanceId) - return ResponseEntity.ok(reveiws) + val reviews = reviewService.getReviews(performanceId) + return ResponseEntity.ok(reviews) } @PostMapping("/api/v1/performance/{performanceId}/review") fun createReview( @RequestBody request: CreateReviewRequest, @PathVariable performanceId: String, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - val review = reviewService.createReview(user, performanceId, request.rating, request.title, request.content) + val review = reviewService.createReview(userDetails.getUserId(), performanceId, request.rating, request.title, request.content) return ResponseEntity.status(201).body(review) } @@ -43,18 +41,18 @@ class ReviewController( fun editReview( @RequestBody request: EditReviewRequest, @PathVariable reviewId: String, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - val review = reviewService.editReview(user, reviewId, request.rating, request.title, request.content) + val review = reviewService.editReview(userDetails.getUserId(), reviewId, request.rating, request.title, request.content) return ResponseEntity.ok(review) } @DeleteMapping("/api/v1/review/{reviewId}") fun deleteReview( @PathVariable reviewId: String, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - val review = reviewService.deleteReview(user, reviewId) + val review = reviewService.deleteReview(userDetails.getUserId(), reviewId) return ResponseEntity.noContent().build() } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt index 80a1529..8b3d574 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt @@ -1,8 +1,6 @@ package com.wafflestudio.interpark.review.service import com.wafflestudio.interpark.review.* -import com.wafflestudio.interpark.review.controller.Review -import com.wafflestudio.interpark.review.persistence.ReviewEntity import com.wafflestudio.interpark.review.persistence.ReviewRepository import com.wafflestudio.interpark.review.controller.Reply import com.wafflestudio.interpark.review.persistence.ReplyEntity @@ -24,11 +22,10 @@ class ReplyService( private val userRepository: UserRepository, ) { - fun getRepliesByUser(user: User): List { - val authorId = user.id - val replies: List = + fun getRepliesByUser(userId: String): List { + val replies: List = replyRepository - .findByAuthorId(authorId) + .findByAuthorId(userId) .map { Reply.fromEntity(it) } return replies } @@ -44,12 +41,12 @@ class ReplyService( @Transactional fun createReply( - author: User, + authorId: String, reviewId: String, content: String, ): Reply { validateContent(content) - val authorEntity = userRepository.findByIdOrNull(author.id) ?: throw AuthenticateException() + val authorEntity = userRepository.findByIdOrNull(authorId) ?: throw AuthenticateException() val reviewEntity = reviewRepository.findByIdOrNull(reviewId) ?: throw ReviewNotFoundException() val replyEntity = ReplyEntity( @@ -67,13 +64,13 @@ class ReplyService( @Transactional fun editReply( - author: User, + authorId: String, replyId: String, content: String, ): Reply { content?.let { validateContent(it) } val replyEntity = replyRepository.findByIdOrNull(replyId) ?: throw ReplyNotFoundException() - val authorEntity = userRepository.findByIdOrNull(author.id) ?: throw AuthenticateException() + val authorEntity = userRepository.findByIdOrNull(authorId) ?: throw AuthenticateException() if (replyEntity.author.id != authorEntity.id) { throw ReplyPermissionDeniedException() } @@ -85,11 +82,11 @@ class ReplyService( @Transactional fun deleteReply( - author: User, + authorId: String, replyId: String, ) { val replyEntity = replyRepository.findByIdOrNull(replyId) ?: throw ReplyNotFoundException() - val authorEntity = userRepository.findByIdOrNull(author.id) ?: throw AuthenticateException() + val authorEntity = userRepository.findByIdOrNull(authorId) ?: throw AuthenticateException() if (replyEntity.author.id != authorEntity.id) { throw ReplyPermissionDeniedException() } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt index d7c811d..c52637a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt @@ -19,11 +19,10 @@ class ReviewService( private val reviewRepository: ReviewRepository, private val userRepository: UserRepository, ) { - fun getReviewsByUser(user: User): List { - val authorId = user.id; + fun getReviewsByUser(userId: String): List { val reviews: List = reviewRepository - .findByAuthorId(authorId) + .findByAuthorId(userId) .map { Review.fromEntity(it) } return reviews } @@ -39,7 +38,7 @@ class ReviewService( @Transactional fun createReview( - author: User, + authorId: String, performanceId: String, rating: Int, title: String, @@ -50,7 +49,7 @@ class ReviewService( val performanceIdString = performanceId // val performanceEntity = entityManager.getReference(PerformanceEntity::class.java, performanceId) // val performanceEntity = performanceRepository.findByIdOrNull(performanceId) ?: throw PerformanceNotFoundException() - val authorEntity = userRepository.findByIdOrNull(author.id) ?: throw AuthenticateException() + val authorEntity = userRepository.findByIdOrNull(authorId) ?: throw AuthenticateException() val reviewEntity = ReviewEntity( id = "", @@ -70,7 +69,7 @@ class ReviewService( @Transactional fun editReview( - author: User, + authorId: String, reviewId: String, rating: Int?, title: String?, @@ -79,7 +78,7 @@ class ReviewService( content?.let { validateContent(it) } rating?.let { validateRating(it) } val reviewEntity = reviewRepository.findByIdOrNull(reviewId) ?: throw ReviewNotFoundException() - val authorEntity = userRepository.findByIdOrNull(author.id) ?: throw AuthenticateException() + val authorEntity = userRepository.findByIdOrNull(authorId) ?: throw AuthenticateException() if (reviewEntity.author.id != authorEntity.id) { throw ReviewPermissionDeniedException() } @@ -93,11 +92,11 @@ class ReviewService( @Transactional fun deleteReview( - author: User, + authorId: String, reviewId: String, ) { val reviewEntity = reviewRepository.findByIdOrNull(reviewId) ?: throw ReviewNotFoundException() - val authorEntity = userRepository.findByIdOrNull(author.id) ?: throw AuthenticateException() + val authorEntity = userRepository.findByIdOrNull(authorId) ?: throw AuthenticateException() if (reviewEntity.author.id != authorEntity.id) { throw ReviewPermissionDeniedException() } diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt index 7968bda..8dd87e9 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt @@ -3,7 +3,9 @@ package com.wafflestudio.interpark.seat.controller import com.wafflestudio.interpark.seat.service.SeatService import com.wafflestudio.interpark.user.AuthUser import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.controller.UserDetailsImpl import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -26,35 +28,35 @@ class SeatController( @PostMapping("/api/v1/reservation/reserve") fun reserveSeat( @RequestBody request: ReserveSeatRequest, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - val reservationId = seatService.reserveSeat(user, request.reservationId) + val reservationId = seatService.reserveSeat(userDetails.getUserId(), request.reservationId) return ResponseEntity.status(200).body(ReserveSeatResponse(reservationId)) } @GetMapping("/api/v1/me/reservation") fun getMyReservations( - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - val myReservations = seatService.getMyReservations(user) + val myReservations = seatService.getMyReservations(userDetails.getUserId()) return ResponseEntity.ok(GetMyReservationsResponse(myReservations)) } @GetMapping("/api/v1/reservation/detail/{reservationId}") fun getReservedSeatDetail( @PathVariable reservationId: String, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - val reservationDetail = seatService.getReservedSeatDetail(user, reservationId) + val reservationDetail = seatService.getReservedSeatDetail(userDetails.getUserId(), reservationId) return ResponseEntity.status(200).body(GetReservedSeatDetailResponse(reservationDetail)) } @PostMapping("/api/v1/reservation/cancel") fun cancelReservedSeat( @RequestBody request: CancelReserveSeatRequest, - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - seatService.cancelReservedSeat(user, request.reservationId) + seatService.cancelReservedSeat(userDetails.getUserId(), request.reservationId) return ResponseEntity.noContent().build() } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt index 1f1a912..8b9778f 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt @@ -12,7 +12,6 @@ import com.wafflestudio.interpark.seat.controller.Seat import com.wafflestudio.interpark.seat.persistence.ReservationRepository import com.wafflestudio.interpark.seat.persistence.SeatRepository import com.wafflestudio.interpark.user.AuthenticateException -import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.persistence.UserRepository import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service @@ -35,10 +34,10 @@ class SeatService( @Transactional fun reserveSeat( - user: User, + userId: String, reservationId: String, ): String { - val targetUser = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() + val targetUser = userRepository.findByIdOrNull(userId) ?: throw AuthenticateException() val targetReservation = reservationRepository.findByIdWithWriteLock(reservationId) ?: throw ReservationNotFoundException() if (targetReservation.reserved) throw ReservedAlreadyException() @@ -52,9 +51,9 @@ class SeatService( } @Transactional - fun getMyReservations(user: User): List { - userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() - val myReservations = reservationRepository.findByUserId(user.id) + fun getMyReservations(userId: String): List { + userRepository.findByIdOrNull(userId) ?: throw AuthenticateException() + val myReservations = reservationRepository.findByUserId(userId) return myReservations.map { reservationEntity -> val performanceEventEntity = reservationEntity.performanceEvent @@ -70,11 +69,11 @@ class SeatService( @Transactional fun getReservedSeatDetail( - user: User, + userId: String, reservationId: String, ): Reservation { val reservationEntity = reservationRepository.findByIdOrNull(reservationId) ?: throw ReservationNotFoundException() - val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() + val userEntity = userRepository.findByIdOrNull(userId) ?: throw AuthenticateException() val reservationUser = reservationEntity.user ?: throw ReservedYetException() if (reservationUser.id != userEntity.id) { throw ReservationPermissionDeniedException() @@ -98,11 +97,11 @@ class SeatService( @Transactional fun cancelReservedSeat( - user: User, + userId: String, reservationId: String, ) { val reservationEntity = reservationRepository.findByIdOrNull(reservationId) ?: throw ReservationNotFoundException() - val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() + val userEntity = userRepository.findByIdOrNull(userId) ?: throw AuthenticateException() val reservationUser = reservationEntity.user ?: throw ReservedYetException() if (reservationUser.id != userEntity.id) { throw ReservationPermissionDeniedException() diff --git a/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..6f53488 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt @@ -0,0 +1,53 @@ +package com.wafflestudio.interpark.security + +import com.wafflestudio.interpark.user.UserAccessTokenUtil +import com.wafflestudio.interpark.user.controller.UserDetailsImpl +import com.wafflestudio.interpark.user.service.UserDetailsServiceImpl +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.web.filter.OncePerRequestFilter +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component + +@Component +class JwtAuthenticationFilter( + private val userAccessTokenUtil: UserAccessTokenUtil, + private val userDetailsService: UserDetailsServiceImpl +) : OncePerRequestFilter() { + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + chain: FilterChain + ) { + // 1) 헤더에서 "Authorization" 값 추출 + val header = request.getHeader("Authorization") + // 예: "Authorization: Bearer " + if (!header.isNullOrBlank() && header.startsWith("Bearer ")) { + val accessToken = header.split(" ")[1] + + // 2) UserAccessTokenUtil로 토큰 유효성 검사 + // 유효하면 userId가 반환되고, 유효하지 않으면 null + val subject = userAccessTokenUtil.validateAccessToken(accessToken) + + if (subject != null) { + // 3) subject(userId)를 기반으로 DB에서 유저/권한 정보 조회 (UserDetails) + val userDetails = userDetailsService.loadUserByUserId(subject) + + // 4) Spring Security에 Authentication 등록 + val authentication = UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.authorities + ) + SecurityContextHolder.getContext().authentication = authentication + } + } + + // 5) 체인 계속 진행 + chain.doFilter(request, response) + } +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/security/RestAccessDeniedHandler.kt b/src/main/kotlin/com/wafflestudio/interpark/security/RestAccessDeniedHandler.kt new file mode 100644 index 0000000..6477647 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/security/RestAccessDeniedHandler.kt @@ -0,0 +1,29 @@ +package com.wafflestudio.interpark.security + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.stereotype.Component +import org.springframework.security.access.AccessDeniedException + +@Component +class RestAccessDeniedHandler : AccessDeniedHandler { + override fun handle( + request: HttpServletRequest, + response: HttpServletResponse, + accessDeniedException: AccessDeniedException + ) { + response.status = HttpServletResponse.SC_FORBIDDEN // 403 + response.contentType = "application/json" + response.characterEncoding = "UTF-8" + response.writer.write( + """ + { + "error": "Access Denied", + "message": "${accessDeniedException.message}", + "path": "${request.requestURI}" + } + """.trimIndent() + ) + } +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt new file mode 100644 index 0000000..dd4aa4f --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt @@ -0,0 +1,59 @@ +package com.wafflestudio.interpark.security + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpMethod +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.config.annotation.web.invoke +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +@Configuration +@EnableWebSecurity +class SecurityConfig ( + private val jwtAuthenticationFilter: JwtAuthenticationFilter, +) { + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + cors { disable() } + csrf { disable() } + sessionManagement { + sessionCreationPolicy = SessionCreationPolicy.STATELESS // 세션 비활성화 + } + authorizeHttpRequests { + // 사용자 권한 + authorize(HttpMethod.GET, "/api/v1/performance/search", permitAll) // 공연 조회 + authorize(HttpMethod.GET, "/api/v1/performance/{performanceId}", permitAll) // 공연 상세정보 반환 + authorize(HttpMethod.GET, "/api/v1/performance-event", permitAll) + authorize(HttpMethod.GET, "/api/v1/performance-event/{performanceId}/{performanceDate}", permitAll) + authorize(HttpMethod.GET, "/api/v1/performance-hall", permitAll) + authorize(HttpMethod.GET, "/api/v1/ping", permitAll) + authorize(HttpMethod.POST, "/api/v1/local/signup", permitAll) + authorize(HttpMethod.POST, "/api/v1/local/signin", permitAll) + authorize(HttpMethod.POST, "/api/v1/auth/signout", permitAll) + authorize(HttpMethod.POST, "/api/v1/auth/refresh_token", permitAll) + authorize(HttpMethod.GET, "/api/v1/seat/{performanceEventId}/available", permitAll) + authorize(HttpMethod.GET, "/api/v1/performance/{performanceId}/review", permitAll) + authorize(HttpMethod.GET, "/api/v1/review/{reviewId}/reply", permitAll) + authorize("/api/v1/**", hasAnyRole("USER", "ADMIN")) // 그 외 모두 유저 권한 필요 + authorize("/admin/v1/**", hasRole("ADMIN")) + + // Swagger 관련 경로 허용 + authorize("/swagger-ui/**", permitAll) + authorize("/v3/api-docs/**", permitAll) + authorize("/swagger-resources/**", permitAll) + authorize("/webjars/**", permitAll) + + authorize(anyRequest, authenticated) + } + exceptionHandling { + accessDeniedHandler = RestAccessDeniedHandler() + } + addFilterBefore(jwtAuthenticationFilter) + } + return http.build() + } +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt index 45687cd..f4361f9 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt @@ -10,6 +10,7 @@ import io.swagger.v3.oas.annotations.media.Schema import jakarta.servlet.http.Cookie import jakarta.servlet.http.HttpServletResponse import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* @RestController @@ -120,9 +121,17 @@ class UserController( @GetMapping("/api/v1/users/me") fun me( - @AuthUser user: User, + @AuthenticationPrincipal userDetails: UserDetailsImpl, ): ResponseEntity { - return ResponseEntity.ok(user) + return ResponseEntity.ok( + User( + id = userDetails.getUserId(), + username = userDetails.username, + nickname = userDetails.getNickname(), + phoneNumber = userDetails.getPhoneNumber(), + email = userDetails.getEmail() + ) + ) } @PostMapping("/api/v1/auth/signout") @@ -185,3 +194,7 @@ data class SignInResponse( data class TokenResponse( val accessToken: String, ) + +data class SignOutRequest(val refreshToken: String) + +data class RefreshTokenRequest(val refreshToken: String) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt new file mode 100644 index 0000000..66265e8 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt @@ -0,0 +1,35 @@ +package com.wafflestudio.interpark.user.controller + +import com.wafflestudio.interpark.user.persistence.UserEntity +import com.wafflestudio.interpark.user.persistence.UserIdentityEntity +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails + +class UserDetailsImpl ( + private val userIdentityEntity: UserIdentityEntity +) : UserDetails { + override fun getUsername(): String { + return userIdentityEntity.user.username + } + + override fun getPassword(): String { + return userIdentityEntity.hashedPassword + } + + override fun getAuthorities(): MutableCollection { + return mutableListOf(userIdentityEntity.role) + } + + override fun isAccountNonExpired(): Boolean = true + override fun isAccountNonLocked(): Boolean = true + override fun isCredentialsNonExpired(): Boolean = true + override fun isEnabled(): Boolean = true + + // 필요하면 convenience 메서드 + fun getUserId(): String = userIdentityEntity.user.id!! + fun getNickname(): String = userIdentityEntity.user.nickname + fun getEmail(): String = userIdentityEntity.user.email + fun getAddress(): String? = userIdentityEntity.user.address + fun getPhoneNumber(): String = userIdentityEntity.user.phoneNumber +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt index f6c15e2..81cd197 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt @@ -7,6 +7,7 @@ import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.OneToOne +import org.springframework.security.core.GrantedAuthority @Entity class UserIdentityEntity( @@ -26,6 +27,10 @@ class UserIdentityEntity( val socialId: String? = null, ) -enum class UserRole { - USER, ADMIN -} \ No newline at end of file +enum class UserRole : GrantedAuthority { + USER, ADMIN; + + override fun getAuthority(): String { + return "ROLE_$name" // Spring Security에서 권장하는 ROLE_ 접두사 + } +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityRepository.kt index 628636f..6636bb0 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityRepository.kt @@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository interface UserIdentityRepository : JpaRepository { fun findByUser(user: UserEntity): UserIdentityEntity? fun findByUserId(userId: String): UserIdentityEntity? + fun findByUserUsername(username: String): UserIdentityEntity? } diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt new file mode 100644 index 0000000..307f0d0 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserDetailsServiceImpl.kt @@ -0,0 +1,28 @@ +package com.wafflestudio.interpark.user.service + +import com.wafflestudio.interpark.user.UserIdentityNotFoundException +import com.wafflestudio.interpark.user.controller.UserDetailsImpl +import com.wafflestudio.interpark.user.persistence.UserIdentityEntity +import com.wafflestudio.interpark.user.persistence.UserIdentityRepository +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.stereotype.Service + +@Service +class UserDetailsServiceImpl ( + private val userIdentityRepository: UserIdentityRepository, +) : UserDetailsService { + override fun loadUserByUsername(username: String): UserDetails { + val userIdentityEntity = userIdentityRepository.findByUserUsername(username) + ?: throw UserIdentityNotFoundException() + + return UserDetailsImpl(userIdentityEntity) + } + + fun loadUserByUserId(userId: String): UserDetails { + val userIdentityEntity = userIdentityRepository.findByUserId(userId) + ?: throw UserIdentityNotFoundException() + + return UserDetailsImpl(userIdentityEntity) + } +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt index 521f61b..29dc370 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt @@ -78,6 +78,7 @@ class UserService( userAccessTokenUtil.removeRefreshToken(refreshToken) } + // spring security로 전가돼서 사실상 호출 안 됨. @Transactional fun authenticate(accessToken: String): User { val userId = userAccessTokenUtil.validateAccessToken(accessToken) ?: throw AuthenticateException() @@ -89,8 +90,4 @@ class UserService( fun refreshAccessToken(refreshToken: String): Pair { return userAccessTokenUtil.refreshAccessToken(refreshToken) ?: throw AuthenticateException() } - - fun getUserIdentityByUserId(userId: String): UserIdentityEntity? { - return userIdentityRepository.findByUserId(userId) - } } diff --git a/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt index defc230..0d4f11b 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/UserIntegrationTest.kt @@ -2,14 +2,13 @@ package com.wafflestudio.interpark import com.fasterxml.jackson.databind.ObjectMapper import com.wafflestudio.interpark.user.UserAccessTokenUtil -import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.persistence.UserRepository +import com.wafflestudio.interpark.user.persistence.UserRole import jakarta.servlet.http.Cookie import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest -import org.springframework.data.repository.findByIdOrNull import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get @@ -26,8 +25,6 @@ class UserIntegrationTest constructor( private val mvc: MockMvc, private val mapper: ObjectMapper, - private val userAccessTokenUtil: UserAccessTokenUtil, - private val userRepository: UserRepository, ) { @Test fun `회원가입시에 유저 이름 혹은 비밀번호가 정해진 규칙에 맞지 않는 경우 400 응답을 내려준다`() { @@ -239,7 +236,7 @@ class UserIntegrationTest } @Test - fun `잘못된 인증 토큰으로 인증시 401 응답을 내려준다`() { + fun `잘못된 인증 토큰으로 접근 시 403 응답을 내려준다`() { val (username, password) = "correct4" to "12345678" mvc.perform( post("/api/v1/local/signup") @@ -280,7 +277,61 @@ class UserIntegrationTest get("/api/v1/users/me") .header("Authorization", "Bearer bad"), ) - .andExpect(status().`is`(401)) + .andExpect(status().`is`(403)) + + mvc.perform( + get("/api/v1/users/me") + .header("Authorization", "Bearer $accessToken"), + ) + .andExpect(status().`is`(200)) + .andExpect(jsonPath("$.username").value(username)) + .andExpect(jsonPath("$.nickname").value(username)) + } + + @Test + fun `관리자가 API 엔드포인트에 접근가능하다`(){ + val (username, password) = "correct5" to "12345678" + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + "nickname" to username, + "phoneNumber" to "010-0000-0000", + "email" to "test@example.com", + "role" to UserRole.ADMIN, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ) + .andExpect(status().`is`(200)) + + val accessToken = + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ) + .andExpect(status().`is`(200)) + .andReturn() + .response.getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + mvc.perform( + get("/api/v1/users/me") + .header("Authorization", "Bearer bad"), + ) + .andExpect(status().`is`(403)) mvc.perform( get("/api/v1/users/me") diff --git a/src/test/resources/SeatApi.http b/src/test/resources/SeatApi.http index c232081..f102ad7 100644 --- a/src/test/resources/SeatApi.http +++ b/src/test/resources/SeatApi.http @@ -1,5 +1,5 @@ ### 회원가입 -POST http://localhost:8080/api/v1/local/signup +POST http://localhost:80/api/v1/local/signup Content-Type: application/json { @@ -11,7 +11,7 @@ Content-Type: application/json } ### 로그인 -POST http://localhost:8080/api/v1/local/signin +POST http://localhost:80/api/v1/local/signin Content-Type: application/json { @@ -20,17 +20,17 @@ Content-Type: application/json } ### performance 받기 -GET http://localhost:8080/api/v1/performance/search +GET http://localhost:80/api/v1/performance/search Accept: application/json ### event 찾기 -GET http://localhost:8080/api/v1/performance-event/e1656490-a5be-446e-b119-d8efa393dd05/2025-01-11 +GET http://localhost:80/api/v1/performance-event/e1656490-a5be-446e-b119-d8efa393dd05/2025-01-11 Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkODUyNjcwMy0zM2Y2LTRmM2ItYTdhMC1mOGZlOGQ3NTcxNDAiLCJpYXQiOjE3MzcyNzcyMjQsImV4cCI6MTczNzI3ODEyNH0.KObDWrRBDRZU-iNyFHHherkbxIjigmjpVn-Iv5Lx3yY ### event 받기 -GET http://localhost:8080/api/v1/performance-event +GET http://localhost:80/api/v1/performance-event Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkODUyNjcwMy0zM2Y2LTRmM2ItYTdhMC1mOGZlOGQ3NTcxNDAiLCJpYXQiOjE3MzcyNzcyMjQsImV4cCI6MTczNzI3ODEyNH0.KObDWrRBDRZU-iNyFHHherkbxIjigmjpVn-Iv5Lx3yY ### 가능한 좌석 받기 -GET http://localhost:8080/api/v1/seat/eade9900-a141-4532-bd2c-c362ceb7c8c0/available -Accept: application/json \ No newline at end of file +GET http://localhost:80/api/v1/seat/eade9900-a141-4532-bd2c-c362ceb7c8c0/available +Accept: application/json diff --git a/src/test/resources/UserApi.http b/src/test/resources/UserApi.http index 6811bdf..c246126 100644 --- a/src/test/resources/UserApi.http +++ b/src/test/resources/UserApi.http @@ -1,5 +1,5 @@ -### 회원가입 -POST http://localhost:8080/api/v1/local/signup +### 회원가입(USER) +POST http://localhost:80/api/v1/local/signup Content-Type: application/json { @@ -10,8 +10,21 @@ Content-Type: application/json "email": "test@example.com" } +### 회원가입(ADMIN) +POST http://localhost:80/api/v1/local/signup +Content-Type: application/json + +{ + "username": "adminname", + "password": "12345678", + "nickname": "examplename", + "phoneNumber": "010-0000-0000", + "email": "test@example.com", + "role": "ADMIN" +} + ### 로그인 -POST http://localhost:8080/api/v1/local/signin +POST http://localhost:80/api/v1/local/signin Content-Type: application/json { @@ -20,13 +33,13 @@ Content-Type: application/json } ### 로그아웃 -POST http://localhost:8080/api/v1/auth/signout +POST http://localhost:80/api/v1/auth/signout Content-Type: application/json ### pingpong -GET http://localhost:8080/api/v1/ping +GET http://localhost:80/api/v1/ping Accept: application/json ### 인증 토큰으로 유저 프로필 조회 -GET http://localhost:8080/api/v1/users/me -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJiY2JlMjI5Yi0zYjgyLTRkM2MtOWFjNS03ZWYzZTUyMjViNWYiLCJpYXQiOjE3MzYzMjA2MDksImV4cCI6MTczNjMyMTUwOX0.RJHC9qQvBnt_q4aS81qdpTrTnXfS-qQ-wMygRFXXg8E \ No newline at end of file +GET http://localhost:80/api/v1/users/me +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJiY2JlMjI5Yi0zYjgyLTRkM2MtOWFjNS03ZWYzZTUyMjViNWYiLCJpYXQiOjE3MzYzMjA2MDksImV4cCI6MTczNjMyMTUwOX0.RJHC9qQvBnt_q4aS81qdpTrTnXfS-qQ-wMygRFXXg8E From 5749eebf32311a7d20bd62dda695122187db513a Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Wed, 22 Jan 2025 20:48:44 +0900 Subject: [PATCH 076/162] style: add new line at EOF --- .gitignore | 2 +- .../com/wafflestudio/interpark/review/service/ReviewService.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 9665d0e..7159cd5 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,4 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt index 9770f48..2307fad 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt @@ -148,4 +148,4 @@ class ReviewService( reviewLikeRepository.delete(reviewLikeToDelete) reviewRepository.save(reviewEntity) } -} \ No newline at end of file +} From 181f2e41ba823bb68ba0d4b2245650ed2c35de2e Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Wed, 22 Jan 2025 21:22:46 +0900 Subject: [PATCH 077/162] throw AuthenticateException when accessToken is not valid --- .../security/JwtAuthenticationFilter.kt | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt index 6f53488..3a5bf87 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt @@ -1,11 +1,10 @@ package com.wafflestudio.interpark.security +import com.wafflestudio.interpark.user.AuthenticateException import com.wafflestudio.interpark.user.UserAccessTokenUtil -import com.wafflestudio.interpark.user.controller.UserDetailsImpl import com.wafflestudio.interpark.user.service.UserDetailsServiceImpl import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.web.filter.OncePerRequestFilter import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest @@ -30,21 +29,19 @@ class JwtAuthenticationFilter( val accessToken = header.split(" ")[1] // 2) UserAccessTokenUtil로 토큰 유효성 검사 - // 유효하면 userId가 반환되고, 유효하지 않으면 null - val subject = userAccessTokenUtil.validateAccessToken(accessToken) + // 유효하면 userId가 반환되고, 유효하지 않으면 AuthenticateException 던짐 + val subject = userAccessTokenUtil.validateAccessToken(accessToken) ?: throw AuthenticateException() - if (subject != null) { - // 3) subject(userId)를 기반으로 DB에서 유저/권한 정보 조회 (UserDetails) - val userDetails = userDetailsService.loadUserByUserId(subject) + // 3) subject(userId)를 기반으로 DB에서 유저/권한 정보 조회 (UserDetails) + val userDetails = userDetailsService.loadUserByUserId(subject) - // 4) Spring Security에 Authentication 등록 - val authentication = UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.authorities - ) - SecurityContextHolder.getContext().authentication = authentication - } + // 4) Spring Security에 Authentication 등록 + val authentication = UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.authorities + ) + SecurityContextHolder.getContext().authentication = authentication } // 5) 체인 계속 진행 From 95107845b91b65d4f2e091ee618ddf0ffabbc258 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus <153504213+ChungPlusPlus@users.noreply.github.com> Date: Thu, 23 Jan 2025 19:50:20 +0900 Subject: [PATCH 078/162] Review & Reply (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * merge test - generate performancehallentity.kt * feat: searchPerformance * chore: 도커 컴포즈 dockerfile 작성해서 도커 이미지 잘 만들어지고 http 파일로 테스트까지 가능한 상태입니다 * modify searchPerformance * add: docker-compose * modify build.gradle.kts * add wildcard import in UserService.kt * change Performance attribute 'genre' to 'category' * change category to ENUM Type * add some Init Datas * add some Init Datas * assign posterimageURL * add: Entity 엔티티 작업중 * assign posterimageURL * add: SeatService 임시저장하겠습니다 * feat: SeatService 좌석 확인, 예매, 예매 취소 기능 구현 아직 동시성 처리 안됐습니다 * feat: Add Controller 컨트롤러 추가했습니다 곧 테스트 코드 추가해보겠습니다 * modify searchPerformance, GET요청은 RequestBody를 사용할 수 없다고 하여 다시 RequestParam을 사용하는 원래 방식으로 변경 * feat: SeatCreation performanceHallId와 type을 받아 좌석엔티티들을 자동으로 생성하는 서비스 함수와 performanceEventId를 받아 빈 예약 가능 엔티티들을 자동을 생성하는 서비스를 만들었습니다 추후 createPerformanceHall과 createPerformanceEvent함수를 수정해서 과정을 더 자동화할 수 있을 것 같습니다 * add: SeatIntegrationTest SeatIntegrationTest 만들었습니다 테스트 전부 통과하며 빌드 성공합니다 다만 테스트에 mysql 데이터베이스가 쓰이며 초기화가 자동으로 되지 않아 문제가 생기니 이는 개선이 필요합니다 * chore: use h2 while test application.yaml 파일을 테스트 코드를 위해 하나 추가해줬습니다 테스트코드에서 @Transactional까지 사용한다면 테스트 코드가 서로 영향을 끼치지 않게 됩니다 * fix: Seat Reservation reservation 바뀐 정보 반영하도록 save 사용했고, Detail확인하는 영역은 컨트롤러까지 분리했습니다 * style: seat ktlint seat폴더의 파일들에 ktlint 적용 * modify MakeFile * 공연 검색 기능 구현 * fromEntity parameter change: receive Entities -> receive DTOs * modify searchPerformance endpoint URL * chore: makefile OS 따라서 다르게 작동하게 수정 * 공연 1개 상세 정보 반환 * minor modify: change how null type is handled * 초기 데이터 수정, 공연이벤트는 일단 공연 기간의 첫날과 마지막날만 생성하였음 * modify DataInitializer, SeatIntegrationTest * add PerformanceDetail Uri * make PerformanceItegrationTest.kt * feat: 예매 동시성 동시성 처리 완료 동시성 처리는 잘 되지만, 테스트 코드 작성이 많이 까다로움 * feat: Get My Reservation Api 유저가 로그인한채로 본인의 예약 목록을 확인할 수 있는 기능 추가 * feat: Seat Get TestCode Get API가 정상작동하는지 테스트코드 추가 * modify SearchPerformanceResponse * check auth for create, delete performance * check auth for create, delete performanceEvent, performanceHall * add posterUri attr to BriefPerformanceDetail * minor change * feat: 예매목록 Brief로 주기 기존에 id만 주던 것을 Brief 데이터의 리스트를 주는 것으로 수정 signin을 할 때 User도 같이 반환하도록 수정 * fix: 로그아웃, 토큰 재발급 버그 수정, 엔드포인트 수정 * add UserIdentityNotFoundException * modify PerformanceDurtion * feat: 공연장 생성시 좌석도 함께 생성 * feat: seat 초기화 적용, 버그 수정 PerformanceHall이 생성될 때 Seat을 같이 생성, PerformanceEvent가 생성될 때 Reservation을 같이 생성하도록 변경 * feat: Find PerformanceEvent PerformanceId와 LocalDate로부터 PerformanceEventId를 반환하도록 추가 잘못된 PerformanceEventId로 빈좌석정보를 확인했을 때 에러 반환 * chore: .env 무시 * feat: Reinforce Simultaneous test code 동시에 여러 사람의 접근이 있어도 통과하는지 테스트 코드 추가 동시에 여러 사람이 서로 다른 좌석에 접근할 때 테스트 코드 추가 * feat: Review/Reply 연결 review와 reply 마무리 * comment: user 설명 추가 username과 password의 조건 설명 추가 * chore: 필요없는 코드 지우기 * feat: Sort Reviews and Replies GET을 통해 리뷰나 댓글을 조회할 때 최신순으로 반환한다 * style: add new line at EOF --------- Co-authored-by: Dohyeon Kim --- .../controller/PerformanceEventController.kt | 2 +- .../interpark/review/controller/Reply.kt | 2 +- .../review/controller/ReplyController.kt | 2 +- .../interpark/review/controller/Review.kt | 13 +- .../review/controller/ReviewController.kt | 36 +-- .../review/persistence/ReplyLikeEntity.kt | 25 -- .../review/persistence/ReplyLikeRepository.kt | 18 -- .../review/persistence/ReplyRepository.kt | 4 + .../review/persistence/ReviewEntity.kt | 9 +- .../review/persistence/ReviewRepository.kt | 9 + .../interpark/review/service/ReplyService.kt | 6 +- .../interpark/review/service/ReviewService.kt | 79 +++--- .../user/controller/UserController.kt | 5 +- .../interpark/ReplyIntegrationTest.kt | 238 +++++++++++++++- .../interpark/ReviewIntegrationTest.kt | 113 +++++++- .../interpark/ReviewLikeIntegrationTest.kt | 255 ++++++++++++++++++ 16 files changed, 698 insertions(+), 118 deletions(-) delete mode 100644 src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeEntity.kt delete mode 100644 src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeRepository.kt create mode 100644 src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt index 92caa1a..7fc6ced 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt @@ -34,7 +34,7 @@ class PerformanceEventController( ) return ResponseEntity.ok(performanceEventList) } - + // WARN: THIS IS FOR ADMIN. // TODO: SEPERATE THIS TO OTHER APPLICATION @PostMapping("/admin/v1/performance-event") diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt index 26f324c..b533d39 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt @@ -14,7 +14,7 @@ data class Reply( fun fromEntity(entity: ReplyEntity): Reply { return Reply( id = entity.id!!, - author = entity.author.id!!, + author = entity.author.nickname, content = entity.content, createdAt = entity.createdAt, updatedAt = entity.updatedAt, diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt index e78bcda..5fac5ff 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt @@ -10,7 +10,7 @@ import org.springframework.web.bind.annotation.* class ReplyController( private val replyService: ReplyService, ) { - @GetMapping("/api/v1/user/me/reply") + @GetMapping("/api/v1/me/reply") fun getRepliesByUser( @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity{ diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt index 0c10f8e..3f91d49 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt @@ -6,27 +6,26 @@ import java.time.Instant data class Review( val id: String, val author: String, - val performance: String, - // val stageId: String, val rating: Int, val title: String, val content: String, val createdAt: Instant, val updatedAt: Instant, - // val replyId: List + val likeCount: Int, + val replyCount: Int, ) { companion object { - fun fromEntity(entity: ReviewEntity): Review { + fun fromEntity(entity: ReviewEntity, replyCount: Int): Review { return Review( id = entity.id!!, - author = entity.author.id!!, - performance = entity.performanceId, - // stageId = entity.stageId, + author = entity.author.nickname, rating = entity.rating, title = entity.title, content = entity.content, createdAt = entity.createdAt, updatedAt = entity.updatedAt, + likeCount = entity.reviewLikes.size, + replyCount = replyCount, ) } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt index b20418a..bfb1d00 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt @@ -1,5 +1,7 @@ package com.wafflestudio.interpark.review.controller +import com.wafflestudio.interpark.review.* +import com.wafflestudio.interpark.review.service.ReplyService import com.wafflestudio.interpark.review.service.ReviewService import com.wafflestudio.interpark.user.controller.UserDetailsImpl import org.springframework.http.ResponseEntity @@ -56,23 +58,23 @@ class ReviewController( return ResponseEntity.noContent().build() } - // @PostMapping("/api/v1/reviews/{reviewId}/like") - // fun likeReview( - // @PathVariable reviewId: String, - // @AuthUser user: User, - // ): ResponseEntity { - // reviewService.likeReview(user, reviewId) - // return ResponseEntity.noContent().build() - // } - - // @PostMapping("/api/v1/reviews/{reviewId}/unlike") - // fun unlikeReview( - // @PathVariable reviewId: String, - // @AuthUser user: User, - // ): ResponseEntity { - // reviewService.unlikeReview(user, reviewId) - // return ResponseEntity.noContent().build() - // } + @PostMapping("/api/v1/review/{reviewId}/like") + fun likeReview( + @PathVariable reviewId: String, + @AuthenticationPrincipal userDetails: UserDetailsImpl + ): ResponseEntity { + reviewService.likeReview(userDetails.getUserId(), reviewId) + return ResponseEntity.noContent().build() + } + + @DeleteMapping("/api/v1/review/{reviewId}/like") + fun cancelLikeReview( + @PathVariable reviewId: String, + @AuthenticationPrincipal userDetails: UserDetailsImpl + ): ResponseEntity { + reviewService.cancelLikeReview(userDetails.getUserId(), reviewId) + return ResponseEntity.noContent().build() + } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeEntity.kt deleted file mode 100644 index ad0d61c..0000000 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeEntity.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.wafflestudio.interpark.review.persistence - -import com.wafflestudio.interpark.user.persistence.UserEntity -import jakarta.persistence.* -import java.time.Instant - -@Entity(name = "reply_like") -@Table( - name = "reply_like", - uniqueConstraints = [UniqueConstraint(columnNames = ["reply_id", "user_id"])], - indexes = [ - Index(name = "idx_reply_user", columnList = "reply_id, user_id"), - ], -) -class ReplyLikeEntity( - @Id - @GeneratedValue(strategy = GenerationType.UUID) - val id: String? = null, - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "reply_id", nullable = false) - var reply: ReplyEntity, - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - var user: UserEntity, -) diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeRepository.kt deleted file mode 100644 index bbed3f0..0000000 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeRepository.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.wafflestudio.interpark.review.persistence - -import com.wafflestudio.interpark.user.persistence.UserEntity - -import jakarta.persistence.LockModeType - -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Lock - -interface ReplyLikeRepository : JpaRepository { - fun countByReply(reply: ReplyEntity): Int - - @Lock(LockModeType.PESSIMISTIC_WRITE) - fun findByReplyAndUser( - reply: ReplyEntity, - user: UserEntity, - ): ReplyLikeEntity? -} diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt index 4fa53b6..befd9a5 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt @@ -1,8 +1,12 @@ package com.wafflestudio.interpark.review.persistence import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query interface ReplyRepository : JpaRepository { + @Query("SELECT r FROM ReplyEntity r WHERE r.review.id = :reviewId ORDER BY r.createdAt DESC") fun findByReviewId(reviewId: String): List + @Query("SELECT r FROM ReplyEntity r WHERE r.author.id = :authorId ORDER BY r.createdAt DESC") fun findByAuthorId(authorId: String): List + fun countByReviewId(reviewId: String): Int } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt index 071983d..bf42588 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt @@ -1,5 +1,6 @@ package com.wafflestudio.interpark.review.persistence +import com.wafflestudio.interpark.performance.persistence.PerformanceEntity import com.wafflestudio.interpark.user.persistence.UserEntity import jakarta.persistence.* import java.time.Instant @@ -15,8 +16,9 @@ class ReviewEntity( @JoinColumn(name = "user_id", nullable = false) val author: UserEntity, - @Column(name = "performance_id", nullable = false) - val performanceId: String, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "performance_id", nullable = false) + val performance: PerformanceEntity, @Column(name = "rating", nullable = false) var rating: Int, @@ -32,4 +34,7 @@ class ReviewEntity( @Column(name = "updated_at", nullable = false) var updatedAt: Instant = Instant.now(), + + @OneToMany(mappedBy = "review") + var reviewLikes: List = emptyList(), ) diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt index a1a7fd4..52720c0 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt @@ -1,8 +1,17 @@ package com.wafflestudio.interpark.review.persistence +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 interface ReviewRepository : JpaRepository { + @Query("SELECT r FROM ReviewEntity r WHERE r.performance.id = :performanceId ORDER BY r.createdAt DESC") fun findByPerformanceId(performanceId: String): List + @Query("SELECT r FROM ReviewEntity r WHERE r.author.id = :authorId ORDER BY r.createdAt DESC") fun findByAuthorId(authorId: String): List + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM ReviewEntity r WHERE r.id = :id") + fun findByIdWithWriteLock(id: String): ReviewEntity? } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt index 8b3d574..3db7ebf 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt @@ -30,7 +30,6 @@ class ReplyService( return replies } - // TODO: 검색기능 구현해야 함 fun getReplies(reviewId: String): List { val replies: List = replyRepository @@ -39,6 +38,11 @@ class ReplyService( return replies } + fun countReplies(reviewId: String): Int { + val replyCount = replyRepository.countByReviewId(reviewId) + return replyCount + } + @Transactional fun createReply( authorId: String, diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt index c52637a..2307fad 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt @@ -1,8 +1,12 @@ package com.wafflestudio.interpark.review.service +import com.wafflestudio.interpark.performance.PerformanceNotFoundException +import com.wafflestudio.interpark.performance.persistence.PerformanceRepository import com.wafflestudio.interpark.review.* import com.wafflestudio.interpark.review.controller.Review import com.wafflestudio.interpark.review.persistence.ReviewEntity +import com.wafflestudio.interpark.review.persistence.ReviewLikeEntity +import com.wafflestudio.interpark.review.persistence.ReviewLikeRepository import com.wafflestudio.interpark.review.persistence.ReviewRepository import com.wafflestudio.interpark.user.AuthenticateException import com.wafflestudio.interpark.user.controller.User @@ -15,24 +19,25 @@ import java.time.Instant @Service class ReviewService( - private val entityManager: EntityManager, + private val performanceRepository: PerformanceRepository, private val reviewRepository: ReviewRepository, private val userRepository: UserRepository, + private val reviewLikeRepository: ReviewLikeRepository, + private val replyService: ReplyService, ) { fun getReviewsByUser(userId: String): List { val reviews: List = reviewRepository .findByAuthorId(userId) - .map { Review.fromEntity(it) } + .map { Review.fromEntity(it, replyService.countReplies(it.id)) } return reviews } - // TODO: 검색기능 구현해야 함 fun getReviews(performanceId: String): List { val reviews: List = reviewRepository .findByPerformanceId(performanceId) - .map { Review.fromEntity(it) } + .map { Review.fromEntity(it, replyService.countReplies(it.id)) } return reviews } @@ -46,16 +51,14 @@ class ReviewService( ): Review { validateContent(content) validateRating(rating) - val performanceIdString = performanceId - // val performanceEntity = entityManager.getReference(PerformanceEntity::class.java, performanceId) - // val performanceEntity = performanceRepository.findByIdOrNull(performanceId) ?: throw PerformanceNotFoundException() + + val performanceEntity = performanceRepository.findByIdOrNull(performanceId) ?: throw PerformanceNotFoundException() val authorEntity = userRepository.findByIdOrNull(authorId) ?: throw AuthenticateException() val reviewEntity = ReviewEntity( id = "", author = authorEntity, - performanceId = performanceId, - // performance = performanceEntity, + performance = performanceEntity, title = title, content = content, rating = rating, @@ -64,7 +67,7 @@ class ReviewService( ).let { reviewRepository.save(it) } - return Review.fromEntity(reviewEntity) + return Review.fromEntity(reviewEntity, replyService.countReplies(reviewEntity.id)) } @Transactional @@ -87,7 +90,7 @@ class ReviewService( content?.let { reviewEntity.content = it } reviewEntity.updatedAt = Instant.now() reviewRepository.save(reviewEntity) - return Review.fromEntity(reviewEntity) + return Review.fromEntity(reviewEntity, replyService.countReplies(reviewEntity.id)) } @Transactional @@ -118,31 +121,31 @@ class ReviewService( } } - // @Transactional - // fun likeReview( - // user: User, - // reviewId: String, - // ) { - // val reviewEntity = reviewRepository.findByIdWithWriteLock(reviewId) ?: throw ReviewNotFoundException() - // val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() - // if (reviewEntity.reviewLikes.any { it.user.id == userEntity.id }) { - // return - // } - // val reviewLikeEntity = reviewLikeRepository.save(ReviewLikeEntity(review = reviewEntity, user = userEntity, createdAt = Instant.now(), updatedAt = Instant.now())) - // reviewEntity.reviewLikes += reviewLikeEntity - // reviewRepository.save(reviewEntity) - // } + @Transactional + fun likeReview( + userId: String, + reviewId: String, + ) { + val reviewEntity = reviewRepository.findByIdWithWriteLock(reviewId) ?: throw ReviewNotFoundException() + val userEntity = userRepository.findByIdOrNull(userId) ?: throw AuthenticateException() + if (reviewEntity.reviewLikes.any { it.user.id == userEntity.id }) { + return + } + val reviewLikeEntity = reviewLikeRepository.save(ReviewLikeEntity(review = reviewEntity, user = userEntity)) + reviewEntity.reviewLikes += reviewLikeEntity + reviewRepository.save(reviewEntity) + } - // @Transactional - // fun unlikeReview( - // user: User, - // reviewId: String, - // ) { - // val reviewEntity = reviewRepository.findByIdWithWriteLock(reviewId) ?: throw ReviewNotFoundException() - // val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() - // val reviewToDelete = reviewEntity.reviewLikes.find { it.user.id == userEntity.id } ?: return - // reviewEntity.reviewLikes -= reviewToDelete - // reviewLikeRepository.delete(reviewToDelete) - // reviewRepository.save(reviewEntity) - // } -} \ No newline at end of file + @Transactional + fun cancelLikeReview( + userId: String, + reviewId: String, + ) { + val reviewEntity = reviewRepository.findByIdWithWriteLock(reviewId) ?: throw ReviewNotFoundException() + val userEntity = userRepository.findByIdOrNull(userId) ?: throw AuthenticateException() + val reviewLikeToDelete = reviewEntity.reviewLikes.find { it.user.id == userEntity.id } ?: return + reviewEntity.reviewLikes -= reviewLikeToDelete + reviewLikeRepository.delete(reviewLikeToDelete) + reviewRepository.save(reviewEntity) + } +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt index f4361f9..771aa6e 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt @@ -32,6 +32,7 @@ class UserController( description = """ 새로운 사용자를 등록합니다. 사용자 이름, 비밀번호, 닉네임, 이메일, 전화번호를 입력받아 저장합니다. + useraname은 6~20자, password는 8~12자를 만족해야 합니다 요청이 유효하지 않은 경우 또는 사용자 이름이 중복된 경우 적절한 에러 메시지를 반환합니다. """, responses = [ @@ -194,7 +195,3 @@ data class SignInResponse( data class TokenResponse( val accessToken: String, ) - -data class SignOutRequest(val refreshToken: String) - -data class RefreshTokenRequest(val refreshToken: String) diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt index 8691045..047a892 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt @@ -68,7 +68,18 @@ class ReplyIntegrationTest .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it).get("accessToken").asText() } - performanceId = "sample-performance" + //테스트 용으로 아무 공연 Id를 하나 가져온다 + performanceId = + mvc.perform( + get("/api/v1/performance/search") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performances = mapper.readTree(it) + performances[0].get("id").asText() + } // 3️⃣ 리뷰 생성 (테스트용) reviewId = @@ -240,4 +251,229 @@ class ReplyIntegrationTest ).andExpect(status().`is`(401)) .andExpect(jsonPath("$.error").value("Unauthorized Access To Reply")) } + + @Test + fun `댓글을 달면 댓글수가 증가한다`() { + var reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + var targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("replyCount").asText() == "0") { "expected 0 replyCount but ${targetReview!!.get("reply").asText()}"} + + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "Great review! I totally agree."), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + + reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("replyCount").asText() == "1") { "expected 1 replyCount but ${targetReview!!.get("replyCount").asText()}"} + } + + @Test + fun `댓글은 여러 개를 달 수 있다`() { + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "Great review! I totally agree."), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "I can't stop thinking about this review! I totally agree again."), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "Again and Again and Again"), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + + val reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + val targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("replyCount").asText() == "3") { "expected 3 replyCount but ${targetReview!!.get("replyCount").asText()}"} + } + + @Test + fun `댓글을 지우면 댓글수가 줄어든다`() { + replyId = + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "Great review! I totally agree."), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("id").asText() } + + var reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + var targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("replyCount").asText() == "1") { "expected 1 replyCount but ${targetReview!!.get("replyCount").asText()}"} + + mvc.perform( + delete("/api/v1/reply/$replyId") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(204)) + + reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("replyCount").asText() == "0") { "expected 0 replyCount but ${targetReview!!.get("likeCount").asText()}"} + + } + + @Test + fun `GET을 했을 때 댓글을 최신순으로 정렬되어 반환된다`() { + val otherAccessTokens = (1..5).map { num -> + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "otherMan2$num", + "password" to "goodPassword", + "nickname" to "NICKNAME", + "phoneNumber" to "010-1234-5678", + "email" to "hacker@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "otherMan2$num", + "password" to "goodPassword", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + } + (1..5).forEach { + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer ${otherAccessTokens[it-1]}") + .content( + mapper.writeValueAsString( + mapOf("content" to "$it"), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + } + + (1..5).forEach { + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "$it"), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + } + + val reviewReplyContent = mvc.perform( + get("/api/v1/review/$reviewId/reply") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + .map { it.get("content").asText() } + assert (reviewReplyContent == listOf("5","4","3","2","1","5","4","3","2","1")) { + "expected rating ${listOf("5","4","3","2","1","5","4","3","2","1")} but $reviewReplyContent" + } + + val userReplyContent = mvc.perform( + get("/api/v1/me/reply") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + .map { it.get("content").asText() } + assert (userReplyContent == (5 downTo 1).map {"$it"}) { + "expected rating ${(5 downTo 1).map {"$it"}} but $userReplyContent" + } + } } diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt index 391de23..e6d04c5 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt @@ -68,7 +68,18 @@ class ReviewIntegrationTest .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it).get("accessToken").asText() } - performanceId = "sample-performance" // 가상의 공연 ID + //테스트 용으로 아무 공연 Id를 하나 가져온다 + performanceId = + mvc.perform( + get("/api/v1/performance/search") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performances = mapper.readTree(it) + performances[0].get("id").asText() + } } @Test @@ -97,7 +108,6 @@ class ReviewIntegrationTest // 4️⃣ 리뷰 조회 (성공) mvc.perform( get("/api/v1/performance/$performanceId/review") - .header("Authorization", "Bearer $accessToken"), ).andExpect(status().`is`(200)) .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) // reviewId가 포함된 객체가 존재하는지 확인 @@ -240,4 +250,103 @@ class ReviewIntegrationTest ).andExpect(status().`is`(401)) .andExpect(jsonPath("$.error").value("Unauthorized Access To Review")) } + + @Test + fun `GET을 했을 때 리뷰를 최신순으로 정렬하여 반환된다`() { + val otherAccessTokens = (1..5).map { num -> + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "otherMan$num", + "password" to "goodPassword", + "nickname" to "NICKNAME", + "phoneNumber" to "010-1234-5678", + "email" to "hacker@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "otherMan$num", + "password" to "goodPassword", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + } + + (1..5).forEach { + mvc.perform( + post("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer ${otherAccessTokens[it-1]}") + .content( + mapper.writeValueAsString( + mapOf( + "rating" to it, + "title" to "Great Performance!", + "content" to "Absolutely amazing. Highly recommend!", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + } + + (1..5).forEach { + mvc.perform( + post("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer ${accessToken}") + .content( + mapper.writeValueAsString( + mapOf( + "rating" to it, + "title" to "Great Performance!", + "content" to "Absolutely amazing. Highly recommend!", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + } + + val performanceReviewRating = mvc.perform( + get("/api/v1/performance/$performanceId/review") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + .map { it.get("rating").asInt() } + assert (performanceReviewRating == listOf(5,4,3,2,1,5,4,3,2,1)) { + "expected rating ${(5 downTo 1).toList()} but $performanceReviewRating" + } + + val userReviewRating = mvc.perform( + get("/api/v1/me/review") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + .map { it.get("rating").asInt() } + assert (userReviewRating == (5 downTo 1).toList()) { + "expected rating ${(5 downTo 1).toList()} but $userReviewRating" + } + } } diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt new file mode 100644 index 0000000..ff570be --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt @@ -0,0 +1,255 @@ +package com.wafflestudio.interpark.review + +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Transactional +class ReviewLikeIntegrationTest +@Autowired +constructor( + private val mvc: MockMvc, + private val mapper: ObjectMapper, +) { + private lateinit var accessToken: String + private lateinit var otherAccessToken: String + private lateinit var performanceId: String + private lateinit var reviewId: String + + @BeforeEach + fun setUp() { + val username = UUID.randomUUID().toString().take(8) + val password = "password123" + + // 1️⃣ 회원가입 + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + "nickname" to "reviewer", + "phoneNumber" to "010-0000-0000", + "email" to "reviewer@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + // 2️⃣ 로그인 → 토큰 획득 + accessToken = + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + //다른 사용자로도 좋아요가 되는지 체크 + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "otherUser1", + "password" to "goodPassword", + "nickname" to "otherUser1", + "phoneNumber" to "010-1234-5678", + "email" to "other@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + otherAccessToken = + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "otherUser1", + "password" to "goodPassword", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + //테스트 용으로 아무 공연 Id를 하나 가져온다 + performanceId = + mvc.perform( + get("/api/v1/performance/search") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performances = mapper.readTree(it) + performances[0].get("id").asText() + } + + reviewId = + mvc.perform( + post("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf( + "rating" to 5, + "title" to "Great Performance!", + "content" to "Absolutely amazing. Highly recommend!", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("id").asText() } + } + + @Test + fun `좋아요를 하면 좋아요 수가 증가한다`() { + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + var reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + var targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("likeCount").asText() == "1") { "expected 1 likeCount but ${targetReview!!.get("likeCount").asText()}"} + + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $otherAccessToken") + ).andExpect(status().`is`(204)) + + reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("likeCount").asText() == "2") { "expected 2 likeCount but ${targetReview!!.get("likeCount").asText()}"} + } + + @Test + fun `한 사람이 좋아요를 여러 번 눌러도 좋아요는 1만 증가한다`() { + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + val reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + val targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("likeCount").asText() == "1") { "expected 1 likeCount but ${targetReview!!.get("likeCount").asText()}"} + } + + @Test + fun `좋아요를 취소하면 좋아요 수가 줄어든다`() { + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + mvc.perform( + delete("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + var reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + var targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("likeCount").asText() == "0") { "expected 0 likeCount but ${targetReview!!.get("likeCount").asText()}"} + + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + mvc.perform( + delete("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $otherAccessToken") + ).andExpect(status().`is`(204)) + + // otherUser은 좋아요를 누르지 않았으니 취소해도 좋아요가 줄어들지 않는다 + // 좋아요를 누르지 않은 채로 취소해도 버그는 나지 않는다 + reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("likeCount").asText() == "1") { "expected 1 likeCount but ${targetReview!!.get("likeCount").asText()}"} + } +} From c6599aa01a71180ceda8d050fcfa6c5eeffc7152 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Thu, 23 Jan 2025 20:04:12 +0900 Subject: [PATCH 079/162] save --- .../interpark/config/SwaggerConfig.kt | 38 ++++++++++++++++++ .../interpark/user/UserException.kt | 6 +++ .../user/controller/UserController.kt | 19 +++++++-- .../user/persistence/SocialAccountEntity.kt | 36 +++++++++++++++++ .../persistence/SocialAccountRepository.kt | 7 ++++ .../user/persistence/UserIdentityEntity.kt | 9 +++-- .../interpark/user/service/UserService.kt | 39 ++++++++++++++++++- 7 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/com/wafflestudio/interpark/user/persistence/SocialAccountEntity.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/user/persistence/SocialAccountRepository.kt diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/SwaggerConfig.kt b/src/main/kotlin/com/wafflestudio/interpark/config/SwaggerConfig.kt index 9813b0a..19398d1 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/SwaggerConfig.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/SwaggerConfig.kt @@ -31,6 +31,8 @@ class SwaggerConfig { ) .addSecurityItem(SecurityRequirement().addList("Bearer Authentication")) .addSecurityItem(SecurityRequirement().addList("Google OAuth2")) + .addSecurityItem(SecurityRequirement().addList("Kakao OAuth2")) + .addSecurityItem(SecurityRequirement().addList("Naver OAuth2")) .components( Components() .addSecuritySchemes( @@ -57,6 +59,42 @@ class SwaggerConfig { ) ) ) + .addSecuritySchemes( + "Kakao OAuth2", + SecurityScheme() + .type(SecurityScheme.Type.OAUTH2) + .flows( + OAuthFlows() + .authorizationCode( + OAuthFlow() + .authorizationUrl("https://kauth.kakao.com/oauth/authorize") + .tokenUrl("https://kauth.kakao.com/oauth/token") + .scopes( + Scopes() + .addString("account_email", "email access") + .addString("profile", "profile access") + ) + ) + ) + ) + .addSecuritySchemes( + "Naver OAuth2", + SecurityScheme() + .type(SecurityScheme.Type.OAUTH2) + .flows( + OAuthFlows() + .authorizationCode( + OAuthFlow() + .authorizationUrl("https://nid.naver.com/oauth2.0/authorize") + .tokenUrl("https://nid.naver.com/oauth2.0/token") + .scopes( + Scopes() + .addString("email", "email access") + .addString("name", "name access") + ) + ) + ) + ) ) } } \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt b/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt index 21c19fe..a0b171e 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt @@ -64,3 +64,9 @@ class NoRefreshTokenException : UserException( httpStatusCode = HttpStatus.UNAUTHORIZED, msg = "Token not found", ) + +class SocialAccountAlreadyLinkedException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.CONFLICT, + msg = "Social Account already linked to another user", +) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt index f4361f9..4ad88e5 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt @@ -1,6 +1,7 @@ package com.wafflestudio.interpark.user.controller import com.wafflestudio.interpark.user.* +import com.wafflestudio.interpark.user.persistence.Provider import com.wafflestudio.interpark.user.persistence.UserRole import com.wafflestudio.interpark.user.service.UserService import io.swagger.v3.oas.annotations.Operation @@ -96,6 +97,7 @@ class UserController( email = request.email, phoneNumber = request.phoneNumber, role = request.role, + provider = request.provider, ) return ResponseEntity.ok(SignUpResponse(user)) } @@ -168,6 +170,14 @@ class UserController( return ResponseEntity.ok(TokenResponse(newAccessToken)) } + + @PostMapping("/api/v1/social/link") + fun linkSocialAccount( + @RequestBody request: LinkSocialAccountRequest + ): ResponseEntity { + userService.linkSocialAccount(request.userId, request.provider, request.providerId) + return ResponseEntity.ok().build() + } } data class SignUpRequest( @@ -177,6 +187,7 @@ data class SignUpRequest( val phoneNumber: String, val email: String, val role: UserRole = UserRole.USER, + val provider: Provider? = null, ) data class SignUpResponse(val user: User) @@ -195,6 +206,8 @@ data class TokenResponse( val accessToken: String, ) -data class SignOutRequest(val refreshToken: String) - -data class RefreshTokenRequest(val refreshToken: String) +data class LinkSocialAccountRequest( + val userId: String, + val provider: Provider, + val providerId: String, +) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/SocialAccountEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/SocialAccountEntity.kt new file mode 100644 index 0000000..478877a --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/SocialAccountEntity.kt @@ -0,0 +1,36 @@ +package com.wafflestudio.interpark.user.persistence + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.Id +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne + +@Entity +class SocialAccountEntity ( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_identity_id") + val userIdentity: UserIdentityEntity, + @Column(name = "provider", nullable = false) + val provider: Provider, + @Column(name = "provider_id", nullable = false) + val providerId: String, +) + +enum class Provider(val displayName: String) { + GOOGLE("Google"), + KAKAO("Kakao"), + NAVER("Naver"); + + companion object { + fun fromName(name: String): Provider? { + return entries.find { it.name == name.uppercase() } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/SocialAccountRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/SocialAccountRepository.kt new file mode 100644 index 0000000..fe82d62 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/SocialAccountRepository.kt @@ -0,0 +1,7 @@ +package com.wafflestudio.interpark.user.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface SocialAccountRepository : JpaRepository { + fun findByProviderAndProviderId(provider: Provider, providerId: String): SocialAccountEntity? +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt index 81cd197..8252d0f 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt @@ -1,12 +1,15 @@ package com.wafflestudio.interpark.user.persistence +import jakarta.persistence.CascadeType import jakarta.persistence.Column import jakarta.persistence.Entity +import jakarta.persistence.FetchType import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.OneToOne +import jakarta.persistence.OneToMany import org.springframework.security.core.GrantedAuthority @Entity @@ -21,10 +24,8 @@ class UserIdentityEntity( var role: UserRole = UserRole.USER, @Column(name = "hashed_password", nullable = false) val hashedPassword: String, - @Column(name = "provider", nullable = false) - val provider: String, - @Column(name = "social_id", nullable = true) - val socialId: String? = null, + @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + val socialAccounts: MutableList = mutableListOf(), ) enum class UserRole : GrantedAuthority { diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt index 29dc370..f351fe7 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt @@ -2,6 +2,9 @@ package com.wafflestudio.interpark.user.service import com.wafflestudio.interpark.user.* import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.persistence.Provider +import com.wafflestudio.interpark.user.persistence.SocialAccountEntity +import com.wafflestudio.interpark.user.persistence.SocialAccountRepository import com.wafflestudio.interpark.user.persistence.UserEntity import com.wafflestudio.interpark.user.persistence.UserIdentityEntity import com.wafflestudio.interpark.user.persistence.UserIdentityRepository @@ -17,6 +20,8 @@ class UserService( private val userRepository: UserRepository, private val userIdentityRepository: UserIdentityRepository, private val userAccessTokenUtil: UserAccessTokenUtil, + private val socialAccountRepository: SocialAccountRepository, + ) { @Transactional fun signUp( @@ -26,6 +31,7 @@ class UserService( phoneNumber: String, email: String, role: UserRole = UserRole.USER, + provider: Provider? = null, ): User { if (username.length < 6 || username.length > 20) { throw SignUpBadUsernameException() @@ -51,9 +57,11 @@ class UserService( user = user, role = role, hashedPassword = encryptedPassword, - provider = "self", ), ) + + // TODO: provider가 null이 아니라면 소셜계정 연동해주기 + return User.fromEntity(user) } @@ -90,4 +98,33 @@ class UserService( fun refreshAccessToken(refreshToken: String): Pair { return userAccessTokenUtil.refreshAccessToken(refreshToken) ?: throw AuthenticateException() } + + @Transactional + fun linkSocialAccount( + userId: String, + provider: Provider, + providerId: String + ): UserIdentityEntity { + // 유저 확인 + val userIdentity = userIdentityRepository.findById(userId) + .orElseThrow { UserIdentityNotFoundException() } + + // 소셜 계정 중복 확인 + val existingSocialAccount = socialAccountRepository.findByProviderAndProviderId(provider, providerId) + if (existingSocialAccount != null) { + if (existingSocialAccount.userIdentity.user.id != userId) { + throw SocialAccountAlreadyLinkedException() + } + } + + // 소셜 계정 생성 및 연동 + val socialAccount = SocialAccountEntity( + userIdentity = userIdentity, + provider = provider, + providerId = providerId + ) + socialAccountRepository.save(socialAccount) + + return userIdentity + } } From 95fa741e6a6def3466781f49f32b03ce31372cd8 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus <153504213+ChungPlusPlus@users.noreply.github.com> Date: Thu, 23 Jan 2025 20:31:08 +0900 Subject: [PATCH 080/162] Revert "Review & Reply (#20)" (#21) This reverts commit 95107845b91b65d4f2e091ee618ddf0ffabbc258. --- .../controller/PerformanceEventController.kt | 2 +- .../interpark/review/controller/Reply.kt | 2 +- .../review/controller/ReplyController.kt | 2 +- .../interpark/review/controller/Review.kt | 13 +- .../review/controller/ReviewController.kt | 36 ++- .../review/persistence/ReplyLikeEntity.kt | 25 ++ .../review/persistence/ReplyLikeRepository.kt | 18 ++ .../review/persistence/ReplyRepository.kt | 4 - .../review/persistence/ReviewEntity.kt | 9 +- .../review/persistence/ReviewRepository.kt | 9 - .../interpark/review/service/ReplyService.kt | 6 +- .../interpark/review/service/ReviewService.kt | 79 +++--- .../user/controller/UserController.kt | 5 +- .../interpark/ReplyIntegrationTest.kt | 238 +--------------- .../interpark/ReviewIntegrationTest.kt | 113 +------- .../interpark/ReviewLikeIntegrationTest.kt | 255 ------------------ 16 files changed, 118 insertions(+), 698 deletions(-) create mode 100644 src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeEntity.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeRepository.kt delete mode 100644 src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt index 7fc6ced..92caa1a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt @@ -34,7 +34,7 @@ class PerformanceEventController( ) return ResponseEntity.ok(performanceEventList) } - + // WARN: THIS IS FOR ADMIN. // TODO: SEPERATE THIS TO OTHER APPLICATION @PostMapping("/admin/v1/performance-event") diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt index b533d39..26f324c 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt @@ -14,7 +14,7 @@ data class Reply( fun fromEntity(entity: ReplyEntity): Reply { return Reply( id = entity.id!!, - author = entity.author.nickname, + author = entity.author.id!!, content = entity.content, createdAt = entity.createdAt, updatedAt = entity.updatedAt, diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt index 5fac5ff..e78bcda 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt @@ -10,7 +10,7 @@ import org.springframework.web.bind.annotation.* class ReplyController( private val replyService: ReplyService, ) { - @GetMapping("/api/v1/me/reply") + @GetMapping("/api/v1/user/me/reply") fun getRepliesByUser( @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity{ diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt index 3f91d49..0c10f8e 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt @@ -6,26 +6,27 @@ import java.time.Instant data class Review( val id: String, val author: String, + val performance: String, + // val stageId: String, val rating: Int, val title: String, val content: String, val createdAt: Instant, val updatedAt: Instant, - val likeCount: Int, - val replyCount: Int, + // val replyId: List ) { companion object { - fun fromEntity(entity: ReviewEntity, replyCount: Int): Review { + fun fromEntity(entity: ReviewEntity): Review { return Review( id = entity.id!!, - author = entity.author.nickname, + author = entity.author.id!!, + performance = entity.performanceId, + // stageId = entity.stageId, rating = entity.rating, title = entity.title, content = entity.content, createdAt = entity.createdAt, updatedAt = entity.updatedAt, - likeCount = entity.reviewLikes.size, - replyCount = replyCount, ) } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt index bfb1d00..b20418a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt @@ -1,7 +1,5 @@ package com.wafflestudio.interpark.review.controller -import com.wafflestudio.interpark.review.* -import com.wafflestudio.interpark.review.service.ReplyService import com.wafflestudio.interpark.review.service.ReviewService import com.wafflestudio.interpark.user.controller.UserDetailsImpl import org.springframework.http.ResponseEntity @@ -58,23 +56,23 @@ class ReviewController( return ResponseEntity.noContent().build() } - @PostMapping("/api/v1/review/{reviewId}/like") - fun likeReview( - @PathVariable reviewId: String, - @AuthenticationPrincipal userDetails: UserDetailsImpl - ): ResponseEntity { - reviewService.likeReview(userDetails.getUserId(), reviewId) - return ResponseEntity.noContent().build() - } - - @DeleteMapping("/api/v1/review/{reviewId}/like") - fun cancelLikeReview( - @PathVariable reviewId: String, - @AuthenticationPrincipal userDetails: UserDetailsImpl - ): ResponseEntity { - reviewService.cancelLikeReview(userDetails.getUserId(), reviewId) - return ResponseEntity.noContent().build() - } + // @PostMapping("/api/v1/reviews/{reviewId}/like") + // fun likeReview( + // @PathVariable reviewId: String, + // @AuthUser user: User, + // ): ResponseEntity { + // reviewService.likeReview(user, reviewId) + // return ResponseEntity.noContent().build() + // } + + // @PostMapping("/api/v1/reviews/{reviewId}/unlike") + // fun unlikeReview( + // @PathVariable reviewId: String, + // @AuthUser user: User, + // ): ResponseEntity { + // reviewService.unlikeReview(user, reviewId) + // return ResponseEntity.noContent().build() + // } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeEntity.kt new file mode 100644 index 0000000..ad0d61c --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeEntity.kt @@ -0,0 +1,25 @@ +package com.wafflestudio.interpark.review.persistence + +import com.wafflestudio.interpark.user.persistence.UserEntity +import jakarta.persistence.* +import java.time.Instant + +@Entity(name = "reply_like") +@Table( + name = "reply_like", + uniqueConstraints = [UniqueConstraint(columnNames = ["reply_id", "user_id"])], + indexes = [ + Index(name = "idx_reply_user", columnList = "reply_id, user_id"), + ], +) +class ReplyLikeEntity( + @Id + @GeneratedValue(strategy = GenerationType.UUID) + val id: String? = null, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reply_id", nullable = false) + var reply: ReplyEntity, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + var user: UserEntity, +) diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeRepository.kt new file mode 100644 index 0000000..bbed3f0 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeRepository.kt @@ -0,0 +1,18 @@ +package com.wafflestudio.interpark.review.persistence + +import com.wafflestudio.interpark.user.persistence.UserEntity + +import jakarta.persistence.LockModeType + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock + +interface ReplyLikeRepository : JpaRepository { + fun countByReply(reply: ReplyEntity): Int + + @Lock(LockModeType.PESSIMISTIC_WRITE) + fun findByReplyAndUser( + reply: ReplyEntity, + user: UserEntity, + ): ReplyLikeEntity? +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt index befd9a5..4fa53b6 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt @@ -1,12 +1,8 @@ package com.wafflestudio.interpark.review.persistence import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Query interface ReplyRepository : JpaRepository { - @Query("SELECT r FROM ReplyEntity r WHERE r.review.id = :reviewId ORDER BY r.createdAt DESC") fun findByReviewId(reviewId: String): List - @Query("SELECT r FROM ReplyEntity r WHERE r.author.id = :authorId ORDER BY r.createdAt DESC") fun findByAuthorId(authorId: String): List - fun countByReviewId(reviewId: String): Int } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt index bf42588..071983d 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt @@ -1,6 +1,5 @@ package com.wafflestudio.interpark.review.persistence -import com.wafflestudio.interpark.performance.persistence.PerformanceEntity import com.wafflestudio.interpark.user.persistence.UserEntity import jakarta.persistence.* import java.time.Instant @@ -16,9 +15,8 @@ class ReviewEntity( @JoinColumn(name = "user_id", nullable = false) val author: UserEntity, - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "performance_id", nullable = false) - val performance: PerformanceEntity, + @Column(name = "performance_id", nullable = false) + val performanceId: String, @Column(name = "rating", nullable = false) var rating: Int, @@ -34,7 +32,4 @@ class ReviewEntity( @Column(name = "updated_at", nullable = false) var updatedAt: Instant = Instant.now(), - - @OneToMany(mappedBy = "review") - var reviewLikes: List = emptyList(), ) diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt index 52720c0..a1a7fd4 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt @@ -1,17 +1,8 @@ package com.wafflestudio.interpark.review.persistence -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 interface ReviewRepository : JpaRepository { - @Query("SELECT r FROM ReviewEntity r WHERE r.performance.id = :performanceId ORDER BY r.createdAt DESC") fun findByPerformanceId(performanceId: String): List - @Query("SELECT r FROM ReviewEntity r WHERE r.author.id = :authorId ORDER BY r.createdAt DESC") fun findByAuthorId(authorId: String): List - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT r FROM ReviewEntity r WHERE r.id = :id") - fun findByIdWithWriteLock(id: String): ReviewEntity? } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt index 3db7ebf..8b3d574 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt @@ -30,6 +30,7 @@ class ReplyService( return replies } + // TODO: 검색기능 구현해야 함 fun getReplies(reviewId: String): List { val replies: List = replyRepository @@ -38,11 +39,6 @@ class ReplyService( return replies } - fun countReplies(reviewId: String): Int { - val replyCount = replyRepository.countByReviewId(reviewId) - return replyCount - } - @Transactional fun createReply( authorId: String, diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt index 2307fad..c52637a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt @@ -1,12 +1,8 @@ package com.wafflestudio.interpark.review.service -import com.wafflestudio.interpark.performance.PerformanceNotFoundException -import com.wafflestudio.interpark.performance.persistence.PerformanceRepository import com.wafflestudio.interpark.review.* import com.wafflestudio.interpark.review.controller.Review import com.wafflestudio.interpark.review.persistence.ReviewEntity -import com.wafflestudio.interpark.review.persistence.ReviewLikeEntity -import com.wafflestudio.interpark.review.persistence.ReviewLikeRepository import com.wafflestudio.interpark.review.persistence.ReviewRepository import com.wafflestudio.interpark.user.AuthenticateException import com.wafflestudio.interpark.user.controller.User @@ -19,25 +15,24 @@ import java.time.Instant @Service class ReviewService( - private val performanceRepository: PerformanceRepository, + private val entityManager: EntityManager, private val reviewRepository: ReviewRepository, private val userRepository: UserRepository, - private val reviewLikeRepository: ReviewLikeRepository, - private val replyService: ReplyService, ) { fun getReviewsByUser(userId: String): List { val reviews: List = reviewRepository .findByAuthorId(userId) - .map { Review.fromEntity(it, replyService.countReplies(it.id)) } + .map { Review.fromEntity(it) } return reviews } + // TODO: 검색기능 구현해야 함 fun getReviews(performanceId: String): List { val reviews: List = reviewRepository .findByPerformanceId(performanceId) - .map { Review.fromEntity(it, replyService.countReplies(it.id)) } + .map { Review.fromEntity(it) } return reviews } @@ -51,14 +46,16 @@ class ReviewService( ): Review { validateContent(content) validateRating(rating) - - val performanceEntity = performanceRepository.findByIdOrNull(performanceId) ?: throw PerformanceNotFoundException() + val performanceIdString = performanceId + // val performanceEntity = entityManager.getReference(PerformanceEntity::class.java, performanceId) + // val performanceEntity = performanceRepository.findByIdOrNull(performanceId) ?: throw PerformanceNotFoundException() val authorEntity = userRepository.findByIdOrNull(authorId) ?: throw AuthenticateException() val reviewEntity = ReviewEntity( id = "", author = authorEntity, - performance = performanceEntity, + performanceId = performanceId, + // performance = performanceEntity, title = title, content = content, rating = rating, @@ -67,7 +64,7 @@ class ReviewService( ).let { reviewRepository.save(it) } - return Review.fromEntity(reviewEntity, replyService.countReplies(reviewEntity.id)) + return Review.fromEntity(reviewEntity) } @Transactional @@ -90,7 +87,7 @@ class ReviewService( content?.let { reviewEntity.content = it } reviewEntity.updatedAt = Instant.now() reviewRepository.save(reviewEntity) - return Review.fromEntity(reviewEntity, replyService.countReplies(reviewEntity.id)) + return Review.fromEntity(reviewEntity) } @Transactional @@ -121,31 +118,31 @@ class ReviewService( } } - @Transactional - fun likeReview( - userId: String, - reviewId: String, - ) { - val reviewEntity = reviewRepository.findByIdWithWriteLock(reviewId) ?: throw ReviewNotFoundException() - val userEntity = userRepository.findByIdOrNull(userId) ?: throw AuthenticateException() - if (reviewEntity.reviewLikes.any { it.user.id == userEntity.id }) { - return - } - val reviewLikeEntity = reviewLikeRepository.save(ReviewLikeEntity(review = reviewEntity, user = userEntity)) - reviewEntity.reviewLikes += reviewLikeEntity - reviewRepository.save(reviewEntity) - } + // @Transactional + // fun likeReview( + // user: User, + // reviewId: String, + // ) { + // val reviewEntity = reviewRepository.findByIdWithWriteLock(reviewId) ?: throw ReviewNotFoundException() + // val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() + // if (reviewEntity.reviewLikes.any { it.user.id == userEntity.id }) { + // return + // } + // val reviewLikeEntity = reviewLikeRepository.save(ReviewLikeEntity(review = reviewEntity, user = userEntity, createdAt = Instant.now(), updatedAt = Instant.now())) + // reviewEntity.reviewLikes += reviewLikeEntity + // reviewRepository.save(reviewEntity) + // } - @Transactional - fun cancelLikeReview( - userId: String, - reviewId: String, - ) { - val reviewEntity = reviewRepository.findByIdWithWriteLock(reviewId) ?: throw ReviewNotFoundException() - val userEntity = userRepository.findByIdOrNull(userId) ?: throw AuthenticateException() - val reviewLikeToDelete = reviewEntity.reviewLikes.find { it.user.id == userEntity.id } ?: return - reviewEntity.reviewLikes -= reviewLikeToDelete - reviewLikeRepository.delete(reviewLikeToDelete) - reviewRepository.save(reviewEntity) - } -} + // @Transactional + // fun unlikeReview( + // user: User, + // reviewId: String, + // ) { + // val reviewEntity = reviewRepository.findByIdWithWriteLock(reviewId) ?: throw ReviewNotFoundException() + // val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() + // val reviewToDelete = reviewEntity.reviewLikes.find { it.user.id == userEntity.id } ?: return + // reviewEntity.reviewLikes -= reviewToDelete + // reviewLikeRepository.delete(reviewToDelete) + // reviewRepository.save(reviewEntity) + // } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt index 771aa6e..f4361f9 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt @@ -32,7 +32,6 @@ class UserController( description = """ 새로운 사용자를 등록합니다. 사용자 이름, 비밀번호, 닉네임, 이메일, 전화번호를 입력받아 저장합니다. - useraname은 6~20자, password는 8~12자를 만족해야 합니다 요청이 유효하지 않은 경우 또는 사용자 이름이 중복된 경우 적절한 에러 메시지를 반환합니다. """, responses = [ @@ -195,3 +194,7 @@ data class SignInResponse( data class TokenResponse( val accessToken: String, ) + +data class SignOutRequest(val refreshToken: String) + +data class RefreshTokenRequest(val refreshToken: String) diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt index 047a892..8691045 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt @@ -68,18 +68,7 @@ class ReplyIntegrationTest .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it).get("accessToken").asText() } - //테스트 용으로 아무 공연 Id를 하나 가져온다 - performanceId = - mvc.perform( - get("/api/v1/performance/search") - ).andExpect(status().`is`(200)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { - val performances = mapper.readTree(it) - performances[0].get("id").asText() - } + performanceId = "sample-performance" // 3️⃣ 리뷰 생성 (테스트용) reviewId = @@ -251,229 +240,4 @@ class ReplyIntegrationTest ).andExpect(status().`is`(401)) .andExpect(jsonPath("$.error").value("Unauthorized Access To Reply")) } - - @Test - fun `댓글을 달면 댓글수가 증가한다`() { - var reviews = mvc.perform( - get("/api/v1/performance/$performanceId/review") - .header("Authorization", "Bearer $accessToken"), - ).andExpect(status().`is`(200)) - .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it) } - var targetReview = reviews.find { it.get("id").asText() == reviewId } - assert(targetReview!!.get("replyCount").asText() == "0") { "expected 0 replyCount but ${targetReview!!.get("reply").asText()}"} - - mvc.perform( - post("/api/v1/review/$reviewId/reply") - .header("Authorization", "Bearer $accessToken") - .content( - mapper.writeValueAsString( - mapOf("content" to "Great review! I totally agree."), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(201)) - - reviews = mvc.perform( - get("/api/v1/performance/$performanceId/review") - .header("Authorization", "Bearer $accessToken"), - ).andExpect(status().`is`(200)) - .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it) } - targetReview = reviews.find { it.get("id").asText() == reviewId } - assert(targetReview!!.get("replyCount").asText() == "1") { "expected 1 replyCount but ${targetReview!!.get("replyCount").asText()}"} - } - - @Test - fun `댓글은 여러 개를 달 수 있다`() { - mvc.perform( - post("/api/v1/review/$reviewId/reply") - .header("Authorization", "Bearer $accessToken") - .content( - mapper.writeValueAsString( - mapOf("content" to "Great review! I totally agree."), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(201)) - - mvc.perform( - post("/api/v1/review/$reviewId/reply") - .header("Authorization", "Bearer $accessToken") - .content( - mapper.writeValueAsString( - mapOf("content" to "I can't stop thinking about this review! I totally agree again."), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(201)) - - mvc.perform( - post("/api/v1/review/$reviewId/reply") - .header("Authorization", "Bearer $accessToken") - .content( - mapper.writeValueAsString( - mapOf("content" to "Again and Again and Again"), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(201)) - - val reviews = mvc.perform( - get("/api/v1/performance/$performanceId/review") - .header("Authorization", "Bearer $accessToken"), - ).andExpect(status().`is`(200)) - .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it) } - val targetReview = reviews.find { it.get("id").asText() == reviewId } - assert(targetReview!!.get("replyCount").asText() == "3") { "expected 3 replyCount but ${targetReview!!.get("replyCount").asText()}"} - } - - @Test - fun `댓글을 지우면 댓글수가 줄어든다`() { - replyId = - mvc.perform( - post("/api/v1/review/$reviewId/reply") - .header("Authorization", "Bearer $accessToken") - .content( - mapper.writeValueAsString( - mapOf("content" to "Great review! I totally agree."), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(201)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it).get("id").asText() } - - var reviews = mvc.perform( - get("/api/v1/performance/$performanceId/review") - .header("Authorization", "Bearer $accessToken"), - ).andExpect(status().`is`(200)) - .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it) } - var targetReview = reviews.find { it.get("id").asText() == reviewId } - assert(targetReview!!.get("replyCount").asText() == "1") { "expected 1 replyCount but ${targetReview!!.get("replyCount").asText()}"} - - mvc.perform( - delete("/api/v1/reply/$replyId") - .header("Authorization", "Bearer $accessToken"), - ).andExpect(status().`is`(204)) - - reviews = mvc.perform( - get("/api/v1/performance/$performanceId/review") - .header("Authorization", "Bearer $accessToken"), - ).andExpect(status().`is`(200)) - .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it) } - targetReview = reviews.find { it.get("id").asText() == reviewId } - assert(targetReview!!.get("replyCount").asText() == "0") { "expected 0 replyCount but ${targetReview!!.get("likeCount").asText()}"} - - } - - @Test - fun `GET을 했을 때 댓글을 최신순으로 정렬되어 반환된다`() { - val otherAccessTokens = (1..5).map { num -> - mvc.perform( - post("/api/v1/local/signup") - .content( - mapper.writeValueAsString( - mapOf( - "username" to "otherMan2$num", - "password" to "goodPassword", - "nickname" to "NICKNAME", - "phoneNumber" to "010-1234-5678", - "email" to "hacker@example.com", - ), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(200)) - - mvc.perform( - post("/api/v1/local/signin") - .content( - mapper.writeValueAsString( - mapOf( - "username" to "otherMan2$num", - "password" to "goodPassword", - ), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(200)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it).get("accessToken").asText() } - } - (1..5).forEach { - mvc.perform( - post("/api/v1/review/$reviewId/reply") - .header("Authorization", "Bearer ${otherAccessTokens[it-1]}") - .content( - mapper.writeValueAsString( - mapOf("content" to "$it"), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(201)) - .andReturn() - } - - (1..5).forEach { - mvc.perform( - post("/api/v1/review/$reviewId/reply") - .header("Authorization", "Bearer $accessToken") - .content( - mapper.writeValueAsString( - mapOf("content" to "$it"), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(201)) - .andReturn() - } - - val reviewReplyContent = mvc.perform( - get("/api/v1/review/$reviewId/reply") - ).andExpect(status().`is`(200)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it) } - .map { it.get("content").asText() } - assert (reviewReplyContent == listOf("5","4","3","2","1","5","4","3","2","1")) { - "expected rating ${listOf("5","4","3","2","1","5","4","3","2","1")} but $reviewReplyContent" - } - - val userReplyContent = mvc.perform( - get("/api/v1/me/reply") - .header("Authorization", "Bearer $accessToken") - ).andExpect(status().`is`(200)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it) } - .map { it.get("content").asText() } - assert (userReplyContent == (5 downTo 1).map {"$it"}) { - "expected rating ${(5 downTo 1).map {"$it"}} but $userReplyContent" - } - } } diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt index e6d04c5..391de23 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt @@ -68,18 +68,7 @@ class ReviewIntegrationTest .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it).get("accessToken").asText() } - //테스트 용으로 아무 공연 Id를 하나 가져온다 - performanceId = - mvc.perform( - get("/api/v1/performance/search") - ).andExpect(status().`is`(200)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { - val performances = mapper.readTree(it) - performances[0].get("id").asText() - } + performanceId = "sample-performance" // 가상의 공연 ID } @Test @@ -108,6 +97,7 @@ class ReviewIntegrationTest // 4️⃣ 리뷰 조회 (성공) mvc.perform( get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), ).andExpect(status().`is`(200)) .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) // reviewId가 포함된 객체가 존재하는지 확인 @@ -250,103 +240,4 @@ class ReviewIntegrationTest ).andExpect(status().`is`(401)) .andExpect(jsonPath("$.error").value("Unauthorized Access To Review")) } - - @Test - fun `GET을 했을 때 리뷰를 최신순으로 정렬하여 반환된다`() { - val otherAccessTokens = (1..5).map { num -> - mvc.perform( - post("/api/v1/local/signup") - .content( - mapper.writeValueAsString( - mapOf( - "username" to "otherMan$num", - "password" to "goodPassword", - "nickname" to "NICKNAME", - "phoneNumber" to "010-1234-5678", - "email" to "hacker@example.com", - ), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(200)) - - mvc.perform( - post("/api/v1/local/signin") - .content( - mapper.writeValueAsString( - mapOf( - "username" to "otherMan$num", - "password" to "goodPassword", - ), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(200)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it).get("accessToken").asText() } - } - - (1..5).forEach { - mvc.perform( - post("/api/v1/performance/$performanceId/review") - .header("Authorization", "Bearer ${otherAccessTokens[it-1]}") - .content( - mapper.writeValueAsString( - mapOf( - "rating" to it, - "title" to "Great Performance!", - "content" to "Absolutely amazing. Highly recommend!", - ), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(201)) - .andReturn() - } - - (1..5).forEach { - mvc.perform( - post("/api/v1/performance/$performanceId/review") - .header("Authorization", "Bearer ${accessToken}") - .content( - mapper.writeValueAsString( - mapOf( - "rating" to it, - "title" to "Great Performance!", - "content" to "Absolutely amazing. Highly recommend!", - ), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(201)) - .andReturn() - } - - val performanceReviewRating = mvc.perform( - get("/api/v1/performance/$performanceId/review") - ).andExpect(status().`is`(200)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it) } - .map { it.get("rating").asInt() } - assert (performanceReviewRating == listOf(5,4,3,2,1,5,4,3,2,1)) { - "expected rating ${(5 downTo 1).toList()} but $performanceReviewRating" - } - - val userReviewRating = mvc.perform( - get("/api/v1/me/review") - .header("Authorization", "Bearer $accessToken") - ).andExpect(status().`is`(200)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it) } - .map { it.get("rating").asInt() } - assert (userReviewRating == (5 downTo 1).toList()) { - "expected rating ${(5 downTo 1).toList()} but $userReviewRating" - } - } } diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt deleted file mode 100644 index ff570be..0000000 --- a/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt +++ /dev/null @@ -1,255 +0,0 @@ -package com.wafflestudio.interpark.review - -import com.fasterxml.jackson.databind.ObjectMapper -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.http.MediaType -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* -import org.springframework.transaction.annotation.Transactional -import java.util.UUID - -@AutoConfigureMockMvc -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Transactional -class ReviewLikeIntegrationTest -@Autowired -constructor( - private val mvc: MockMvc, - private val mapper: ObjectMapper, -) { - private lateinit var accessToken: String - private lateinit var otherAccessToken: String - private lateinit var performanceId: String - private lateinit var reviewId: String - - @BeforeEach - fun setUp() { - val username = UUID.randomUUID().toString().take(8) - val password = "password123" - - // 1️⃣ 회원가입 - mvc.perform( - post("/api/v1/local/signup") - .content( - mapper.writeValueAsString( - mapOf( - "username" to username, - "password" to password, - "nickname" to "reviewer", - "phoneNumber" to "010-0000-0000", - "email" to "reviewer@example.com", - ), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(200)) - - // 2️⃣ 로그인 → 토큰 획득 - accessToken = - mvc.perform( - post("/api/v1/local/signin") - .content( - mapper.writeValueAsString( - mapOf( - "username" to username, - "password" to password, - ), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(200)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it).get("accessToken").asText() } - - //다른 사용자로도 좋아요가 되는지 체크 - mvc.perform( - post("/api/v1/local/signup") - .content( - mapper.writeValueAsString( - mapOf( - "username" to "otherUser1", - "password" to "goodPassword", - "nickname" to "otherUser1", - "phoneNumber" to "010-1234-5678", - "email" to "other@example.com", - ), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(200)) - - otherAccessToken = - mvc.perform( - post("/api/v1/local/signin") - .content( - mapper.writeValueAsString( - mapOf( - "username" to "otherUser1", - "password" to "goodPassword", - ), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(200)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it).get("accessToken").asText() } - - //테스트 용으로 아무 공연 Id를 하나 가져온다 - performanceId = - mvc.perform( - get("/api/v1/performance/search") - ).andExpect(status().`is`(200)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { - val performances = mapper.readTree(it) - performances[0].get("id").asText() - } - - reviewId = - mvc.perform( - post("/api/v1/performance/$performanceId/review") - .header("Authorization", "Bearer $accessToken") - .content( - mapper.writeValueAsString( - mapOf( - "rating" to 5, - "title" to "Great Performance!", - "content" to "Absolutely amazing. Highly recommend!", - ), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(201)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it).get("id").asText() } - } - - @Test - fun `좋아요를 하면 좋아요 수가 증가한다`() { - mvc.perform( - post("/api/v1/review/$reviewId/like") - .header("Authorization", "Bearer $accessToken") - ).andExpect(status().`is`(204)) - - var reviews = mvc.perform( - get("/api/v1/performance/$performanceId/review") - .header("Authorization", "Bearer $accessToken"), - ).andExpect(status().`is`(200)) - .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it) } - var targetReview = reviews.find { it.get("id").asText() == reviewId } - assert(targetReview!!.get("likeCount").asText() == "1") { "expected 1 likeCount but ${targetReview!!.get("likeCount").asText()}"} - - mvc.perform( - post("/api/v1/review/$reviewId/like") - .header("Authorization", "Bearer $otherAccessToken") - ).andExpect(status().`is`(204)) - - reviews = mvc.perform( - get("/api/v1/performance/$performanceId/review") - .header("Authorization", "Bearer $accessToken"), - ).andExpect(status().`is`(200)) - .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it) } - targetReview = reviews.find { it.get("id").asText() == reviewId } - assert(targetReview!!.get("likeCount").asText() == "2") { "expected 2 likeCount but ${targetReview!!.get("likeCount").asText()}"} - } - - @Test - fun `한 사람이 좋아요를 여러 번 눌러도 좋아요는 1만 증가한다`() { - mvc.perform( - post("/api/v1/review/$reviewId/like") - .header("Authorization", "Bearer $accessToken") - ).andExpect(status().`is`(204)) - - mvc.perform( - post("/api/v1/review/$reviewId/like") - .header("Authorization", "Bearer $accessToken") - ).andExpect(status().`is`(204)) - - mvc.perform( - post("/api/v1/review/$reviewId/like") - .header("Authorization", "Bearer $accessToken") - ).andExpect(status().`is`(204)) - - val reviews = mvc.perform( - get("/api/v1/performance/$performanceId/review") - .header("Authorization", "Bearer $accessToken"), - ).andExpect(status().`is`(200)) - .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it) } - val targetReview = reviews.find { it.get("id").asText() == reviewId } - assert(targetReview!!.get("likeCount").asText() == "1") { "expected 1 likeCount but ${targetReview!!.get("likeCount").asText()}"} - } - - @Test - fun `좋아요를 취소하면 좋아요 수가 줄어든다`() { - mvc.perform( - post("/api/v1/review/$reviewId/like") - .header("Authorization", "Bearer $accessToken") - ).andExpect(status().`is`(204)) - - mvc.perform( - delete("/api/v1/review/$reviewId/like") - .header("Authorization", "Bearer $accessToken") - ).andExpect(status().`is`(204)) - - var reviews = mvc.perform( - get("/api/v1/performance/$performanceId/review") - .header("Authorization", "Bearer $accessToken"), - ).andExpect(status().`is`(200)) - .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it) } - var targetReview = reviews.find { it.get("id").asText() == reviewId } - assert(targetReview!!.get("likeCount").asText() == "0") { "expected 0 likeCount but ${targetReview!!.get("likeCount").asText()}"} - - mvc.perform( - post("/api/v1/review/$reviewId/like") - .header("Authorization", "Bearer $accessToken") - ).andExpect(status().`is`(204)) - - mvc.perform( - delete("/api/v1/review/$reviewId/like") - .header("Authorization", "Bearer $otherAccessToken") - ).andExpect(status().`is`(204)) - - // otherUser은 좋아요를 누르지 않았으니 취소해도 좋아요가 줄어들지 않는다 - // 좋아요를 누르지 않은 채로 취소해도 버그는 나지 않는다 - reviews = mvc.perform( - get("/api/v1/performance/$performanceId/review") - .header("Authorization", "Bearer $accessToken"), - ).andExpect(status().`is`(200)) - .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it) } - targetReview = reviews.find { it.get("id").asText() == reviewId } - assert(targetReview!!.get("likeCount").asText() == "1") { "expected 1 likeCount but ${targetReview!!.get("likeCount").asText()}"} - } -} From 7dd94205c225e185821d9ce5888e380372674dab Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Thu, 23 Jan 2025 21:10:28 +0900 Subject: [PATCH 081/162] =?UTF-8?q?hotfix:=20test=20=EC=95=88=EB=90=98?= =?UTF-8?q?=EB=8D=98=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interpark/ReplyIntegrationTest.kt | 19 +++++++++---------- .../interpark/ReviewIntegrationTest.kt | 19 +++++++++---------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt index 047a892..a5096eb 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt @@ -11,6 +11,7 @@ import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* import org.springframework.transaction.annotation.Transactional +import java.time.Instant import java.util.UUID @AutoConfigureMockMvc @@ -451,19 +452,18 @@ class ReplyIntegrationTest .andReturn() } - val reviewReplyContent = mvc.perform( + val reviewReplies = mvc.perform( get("/api/v1/review/$reviewId/reply") ).andExpect(status().`is`(200)) .andReturn() .response .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it) } - .map { it.get("content").asText() } - assert (reviewReplyContent == listOf("5","4","3","2","1","5","4","3","2","1")) { - "expected rating ${listOf("5","4","3","2","1","5","4","3","2","1")} but $reviewReplyContent" - } + .map { Instant.parse(it.get("createdAt").asText()) } + val isReviewReplySorted = reviewReplies.zipWithNext { a,b -> !a.isBefore(b) }.all {it} + assert (isReviewReplySorted) { "expected review rating sorted but not" } - val userReplyContent = mvc.perform( + val userReplies = mvc.perform( get("/api/v1/me/reply") .header("Authorization", "Bearer $accessToken") ).andExpect(status().`is`(200)) @@ -471,9 +471,8 @@ class ReplyIntegrationTest .response .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it) } - .map { it.get("content").asText() } - assert (userReplyContent == (5 downTo 1).map {"$it"}) { - "expected rating ${(5 downTo 1).map {"$it"}} but $userReplyContent" - } + .map { Instant.parse(it.get("createdAt").asText()) } + val isUserReplySorted = userReplies.zipWithNext { a,b -> !a.isBefore(b) }.all {it} + assert (isUserReplySorted) { "expected user reply sorted but not" } } } diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt index e6d04c5..fd3f6c6 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt @@ -12,6 +12,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* import org.springframework.test.web.servlet.result.MockMvcResultHandlers.* import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* import org.springframework.transaction.annotation.Transactional +import java.time.Instant import java.util.UUID @AutoConfigureMockMvc @@ -324,19 +325,18 @@ class ReviewIntegrationTest .andReturn() } - val performanceReviewRating = mvc.perform( + val performanceReviews = mvc.perform( get("/api/v1/performance/$performanceId/review") ).andExpect(status().`is`(200)) .andReturn() .response .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it) } - .map { it.get("rating").asInt() } - assert (performanceReviewRating == listOf(5,4,3,2,1,5,4,3,2,1)) { - "expected rating ${(5 downTo 1).toList()} but $performanceReviewRating" - } + .map { Instant.parse(it.get("createdAt").asText()) } + val isPerformanceReviewSorted = performanceReviews.zipWithNext { a,b -> !a.isBefore(b) }.all {it} + assert (isPerformanceReviewSorted) { "expected sorted performance reviews but not" } - val userReviewRating = mvc.perform( + val userReviews = mvc.perform( get("/api/v1/me/review") .header("Authorization", "Bearer $accessToken") ).andExpect(status().`is`(200)) @@ -344,9 +344,8 @@ class ReviewIntegrationTest .response .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it) } - .map { it.get("rating").asInt() } - assert (userReviewRating == (5 downTo 1).toList()) { - "expected rating ${(5 downTo 1).toList()} but $userReviewRating" - } + .map { Instant.parse(it.get("createdAt").asText()) } + val isUserReviewSorted = userReviews.zipWithNext { a,b -> !a.isBefore(b) }.all {it} + assert (isUserReviewSorted) { "expected sorted user reviews but not" } } } From 2a14088bacc249157ee5466fde61ee0a359cc19a Mon Sep 17 00:00:00 2001 From: ChungPlusPlus <153504213+ChungPlusPlus@users.noreply.github.com> Date: Thu, 23 Jan 2025 21:15:14 +0900 Subject: [PATCH 082/162] =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EC=95=88=EB=90=98?= =?UTF-8?q?=EB=8D=98=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * merge test - generate performancehallentity.kt * feat: searchPerformance * chore: 도커 컴포즈 dockerfile 작성해서 도커 이미지 잘 만들어지고 http 파일로 테스트까지 가능한 상태입니다 * modify searchPerformance * add: docker-compose * modify build.gradle.kts * add wildcard import in UserService.kt * change Performance attribute 'genre' to 'category' * change category to ENUM Type * add some Init Datas * add some Init Datas * assign posterimageURL * add: Entity 엔티티 작업중 * assign posterimageURL * add: SeatService 임시저장하겠습니다 * feat: SeatService 좌석 확인, 예매, 예매 취소 기능 구현 아직 동시성 처리 안됐습니다 * feat: Add Controller 컨트롤러 추가했습니다 곧 테스트 코드 추가해보겠습니다 * modify searchPerformance, GET요청은 RequestBody를 사용할 수 없다고 하여 다시 RequestParam을 사용하는 원래 방식으로 변경 * feat: SeatCreation performanceHallId와 type을 받아 좌석엔티티들을 자동으로 생성하는 서비스 함수와 performanceEventId를 받아 빈 예약 가능 엔티티들을 자동을 생성하는 서비스를 만들었습니다 추후 createPerformanceHall과 createPerformanceEvent함수를 수정해서 과정을 더 자동화할 수 있을 것 같습니다 * add: SeatIntegrationTest SeatIntegrationTest 만들었습니다 테스트 전부 통과하며 빌드 성공합니다 다만 테스트에 mysql 데이터베이스가 쓰이며 초기화가 자동으로 되지 않아 문제가 생기니 이는 개선이 필요합니다 * chore: use h2 while test application.yaml 파일을 테스트 코드를 위해 하나 추가해줬습니다 테스트코드에서 @Transactional까지 사용한다면 테스트 코드가 서로 영향을 끼치지 않게 됩니다 * fix: Seat Reservation reservation 바뀐 정보 반영하도록 save 사용했고, Detail확인하는 영역은 컨트롤러까지 분리했습니다 * style: seat ktlint seat폴더의 파일들에 ktlint 적용 * modify MakeFile * 공연 검색 기능 구현 * fromEntity parameter change: receive Entities -> receive DTOs * modify searchPerformance endpoint URL * chore: makefile OS 따라서 다르게 작동하게 수정 * 공연 1개 상세 정보 반환 * minor modify: change how null type is handled * 초기 데이터 수정, 공연이벤트는 일단 공연 기간의 첫날과 마지막날만 생성하였음 * modify DataInitializer, SeatIntegrationTest * add PerformanceDetail Uri * make PerformanceItegrationTest.kt * feat: 예매 동시성 동시성 처리 완료 동시성 처리는 잘 되지만, 테스트 코드 작성이 많이 까다로움 * feat: Get My Reservation Api 유저가 로그인한채로 본인의 예약 목록을 확인할 수 있는 기능 추가 * feat: Seat Get TestCode Get API가 정상작동하는지 테스트코드 추가 * modify SearchPerformanceResponse * check auth for create, delete performance * check auth for create, delete performanceEvent, performanceHall * add posterUri attr to BriefPerformanceDetail * minor change * feat: 예매목록 Brief로 주기 기존에 id만 주던 것을 Brief 데이터의 리스트를 주는 것으로 수정 signin을 할 때 User도 같이 반환하도록 수정 * fix: 로그아웃, 토큰 재발급 버그 수정, 엔드포인트 수정 * add UserIdentityNotFoundException * modify PerformanceDurtion * feat: 공연장 생성시 좌석도 함께 생성 * feat: seat 초기화 적용, 버그 수정 PerformanceHall이 생성될 때 Seat을 같이 생성, PerformanceEvent가 생성될 때 Reservation을 같이 생성하도록 변경 * feat: Find PerformanceEvent PerformanceId와 LocalDate로부터 PerformanceEventId를 반환하도록 추가 잘못된 PerformanceEventId로 빈좌석정보를 확인했을 때 에러 반환 * chore: .env 무시 * feat: Reinforce Simultaneous test code 동시에 여러 사람의 접근이 있어도 통과하는지 테스트 코드 추가 동시에 여러 사람이 서로 다른 좌석에 접근할 때 테스트 코드 추가 * feat: Review/Reply 연결 review와 reply 마무리 * comment: user 설명 추가 username과 password의 조건 설명 추가 * chore: 필요없는 코드 지우기 * feat: Sort Reviews and Replies GET을 통해 리뷰나 댓글을 조회할 때 최신순으로 반환한다 * style: add new line at EOF * hotfix: test 안되던 오류 수정 --------- Co-authored-by: Dohyeon Kim --- .../controller/PerformanceEventController.kt | 2 +- .../interpark/review/controller/Reply.kt | 2 +- .../review/controller/ReplyController.kt | 2 +- .../interpark/review/controller/Review.kt | 13 +- .../review/controller/ReviewController.kt | 36 +-- .../review/persistence/ReplyLikeEntity.kt | 25 -- .../review/persistence/ReplyLikeRepository.kt | 18 -- .../review/persistence/ReplyRepository.kt | 4 + .../review/persistence/ReviewEntity.kt | 9 +- .../review/persistence/ReviewRepository.kt | 9 + .../interpark/review/service/ReplyService.kt | 6 +- .../interpark/review/service/ReviewService.kt | 79 +++--- .../user/controller/UserController.kt | 5 +- .../interpark/ReplyIntegrationTest.kt | 237 +++++++++++++++- .../interpark/ReviewIntegrationTest.kt | 112 +++++++- .../interpark/ReviewLikeIntegrationTest.kt | 255 ++++++++++++++++++ 16 files changed, 696 insertions(+), 118 deletions(-) delete mode 100644 src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeEntity.kt delete mode 100644 src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeRepository.kt create mode 100644 src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt index 92caa1a..7fc6ced 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt @@ -34,7 +34,7 @@ class PerformanceEventController( ) return ResponseEntity.ok(performanceEventList) } - + // WARN: THIS IS FOR ADMIN. // TODO: SEPERATE THIS TO OTHER APPLICATION @PostMapping("/admin/v1/performance-event") diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt index 26f324c..b533d39 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt @@ -14,7 +14,7 @@ data class Reply( fun fromEntity(entity: ReplyEntity): Reply { return Reply( id = entity.id!!, - author = entity.author.id!!, + author = entity.author.nickname, content = entity.content, createdAt = entity.createdAt, updatedAt = entity.updatedAt, diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt index e78bcda..5fac5ff 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt @@ -10,7 +10,7 @@ import org.springframework.web.bind.annotation.* class ReplyController( private val replyService: ReplyService, ) { - @GetMapping("/api/v1/user/me/reply") + @GetMapping("/api/v1/me/reply") fun getRepliesByUser( @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity{ diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt index 0c10f8e..3f91d49 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt @@ -6,27 +6,26 @@ import java.time.Instant data class Review( val id: String, val author: String, - val performance: String, - // val stageId: String, val rating: Int, val title: String, val content: String, val createdAt: Instant, val updatedAt: Instant, - // val replyId: List + val likeCount: Int, + val replyCount: Int, ) { companion object { - fun fromEntity(entity: ReviewEntity): Review { + fun fromEntity(entity: ReviewEntity, replyCount: Int): Review { return Review( id = entity.id!!, - author = entity.author.id!!, - performance = entity.performanceId, - // stageId = entity.stageId, + author = entity.author.nickname, rating = entity.rating, title = entity.title, content = entity.content, createdAt = entity.createdAt, updatedAt = entity.updatedAt, + likeCount = entity.reviewLikes.size, + replyCount = replyCount, ) } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt index b20418a..bfb1d00 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt @@ -1,5 +1,7 @@ package com.wafflestudio.interpark.review.controller +import com.wafflestudio.interpark.review.* +import com.wafflestudio.interpark.review.service.ReplyService import com.wafflestudio.interpark.review.service.ReviewService import com.wafflestudio.interpark.user.controller.UserDetailsImpl import org.springframework.http.ResponseEntity @@ -56,23 +58,23 @@ class ReviewController( return ResponseEntity.noContent().build() } - // @PostMapping("/api/v1/reviews/{reviewId}/like") - // fun likeReview( - // @PathVariable reviewId: String, - // @AuthUser user: User, - // ): ResponseEntity { - // reviewService.likeReview(user, reviewId) - // return ResponseEntity.noContent().build() - // } - - // @PostMapping("/api/v1/reviews/{reviewId}/unlike") - // fun unlikeReview( - // @PathVariable reviewId: String, - // @AuthUser user: User, - // ): ResponseEntity { - // reviewService.unlikeReview(user, reviewId) - // return ResponseEntity.noContent().build() - // } + @PostMapping("/api/v1/review/{reviewId}/like") + fun likeReview( + @PathVariable reviewId: String, + @AuthenticationPrincipal userDetails: UserDetailsImpl + ): ResponseEntity { + reviewService.likeReview(userDetails.getUserId(), reviewId) + return ResponseEntity.noContent().build() + } + + @DeleteMapping("/api/v1/review/{reviewId}/like") + fun cancelLikeReview( + @PathVariable reviewId: String, + @AuthenticationPrincipal userDetails: UserDetailsImpl + ): ResponseEntity { + reviewService.cancelLikeReview(userDetails.getUserId(), reviewId) + return ResponseEntity.noContent().build() + } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeEntity.kt deleted file mode 100644 index ad0d61c..0000000 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeEntity.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.wafflestudio.interpark.review.persistence - -import com.wafflestudio.interpark.user.persistence.UserEntity -import jakarta.persistence.* -import java.time.Instant - -@Entity(name = "reply_like") -@Table( - name = "reply_like", - uniqueConstraints = [UniqueConstraint(columnNames = ["reply_id", "user_id"])], - indexes = [ - Index(name = "idx_reply_user", columnList = "reply_id, user_id"), - ], -) -class ReplyLikeEntity( - @Id - @GeneratedValue(strategy = GenerationType.UUID) - val id: String? = null, - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "reply_id", nullable = false) - var reply: ReplyEntity, - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - var user: UserEntity, -) diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeRepository.kt deleted file mode 100644 index bbed3f0..0000000 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyLikeRepository.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.wafflestudio.interpark.review.persistence - -import com.wafflestudio.interpark.user.persistence.UserEntity - -import jakarta.persistence.LockModeType - -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Lock - -interface ReplyLikeRepository : JpaRepository { - fun countByReply(reply: ReplyEntity): Int - - @Lock(LockModeType.PESSIMISTIC_WRITE) - fun findByReplyAndUser( - reply: ReplyEntity, - user: UserEntity, - ): ReplyLikeEntity? -} diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt index 4fa53b6..befd9a5 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt @@ -1,8 +1,12 @@ package com.wafflestudio.interpark.review.persistence import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query interface ReplyRepository : JpaRepository { + @Query("SELECT r FROM ReplyEntity r WHERE r.review.id = :reviewId ORDER BY r.createdAt DESC") fun findByReviewId(reviewId: String): List + @Query("SELECT r FROM ReplyEntity r WHERE r.author.id = :authorId ORDER BY r.createdAt DESC") fun findByAuthorId(authorId: String): List + fun countByReviewId(reviewId: String): Int } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt index 071983d..bf42588 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt @@ -1,5 +1,6 @@ package com.wafflestudio.interpark.review.persistence +import com.wafflestudio.interpark.performance.persistence.PerformanceEntity import com.wafflestudio.interpark.user.persistence.UserEntity import jakarta.persistence.* import java.time.Instant @@ -15,8 +16,9 @@ class ReviewEntity( @JoinColumn(name = "user_id", nullable = false) val author: UserEntity, - @Column(name = "performance_id", nullable = false) - val performanceId: String, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "performance_id", nullable = false) + val performance: PerformanceEntity, @Column(name = "rating", nullable = false) var rating: Int, @@ -32,4 +34,7 @@ class ReviewEntity( @Column(name = "updated_at", nullable = false) var updatedAt: Instant = Instant.now(), + + @OneToMany(mappedBy = "review") + var reviewLikes: List = emptyList(), ) diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt index a1a7fd4..52720c0 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt @@ -1,8 +1,17 @@ package com.wafflestudio.interpark.review.persistence +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 interface ReviewRepository : JpaRepository { + @Query("SELECT r FROM ReviewEntity r WHERE r.performance.id = :performanceId ORDER BY r.createdAt DESC") fun findByPerformanceId(performanceId: String): List + @Query("SELECT r FROM ReviewEntity r WHERE r.author.id = :authorId ORDER BY r.createdAt DESC") fun findByAuthorId(authorId: String): List + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM ReviewEntity r WHERE r.id = :id") + fun findByIdWithWriteLock(id: String): ReviewEntity? } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt index 8b3d574..3db7ebf 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt @@ -30,7 +30,6 @@ class ReplyService( return replies } - // TODO: 검색기능 구현해야 함 fun getReplies(reviewId: String): List { val replies: List = replyRepository @@ -39,6 +38,11 @@ class ReplyService( return replies } + fun countReplies(reviewId: String): Int { + val replyCount = replyRepository.countByReviewId(reviewId) + return replyCount + } + @Transactional fun createReply( authorId: String, diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt index c52637a..2307fad 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt @@ -1,8 +1,12 @@ package com.wafflestudio.interpark.review.service +import com.wafflestudio.interpark.performance.PerformanceNotFoundException +import com.wafflestudio.interpark.performance.persistence.PerformanceRepository import com.wafflestudio.interpark.review.* import com.wafflestudio.interpark.review.controller.Review import com.wafflestudio.interpark.review.persistence.ReviewEntity +import com.wafflestudio.interpark.review.persistence.ReviewLikeEntity +import com.wafflestudio.interpark.review.persistence.ReviewLikeRepository import com.wafflestudio.interpark.review.persistence.ReviewRepository import com.wafflestudio.interpark.user.AuthenticateException import com.wafflestudio.interpark.user.controller.User @@ -15,24 +19,25 @@ import java.time.Instant @Service class ReviewService( - private val entityManager: EntityManager, + private val performanceRepository: PerformanceRepository, private val reviewRepository: ReviewRepository, private val userRepository: UserRepository, + private val reviewLikeRepository: ReviewLikeRepository, + private val replyService: ReplyService, ) { fun getReviewsByUser(userId: String): List { val reviews: List = reviewRepository .findByAuthorId(userId) - .map { Review.fromEntity(it) } + .map { Review.fromEntity(it, replyService.countReplies(it.id)) } return reviews } - // TODO: 검색기능 구현해야 함 fun getReviews(performanceId: String): List { val reviews: List = reviewRepository .findByPerformanceId(performanceId) - .map { Review.fromEntity(it) } + .map { Review.fromEntity(it, replyService.countReplies(it.id)) } return reviews } @@ -46,16 +51,14 @@ class ReviewService( ): Review { validateContent(content) validateRating(rating) - val performanceIdString = performanceId - // val performanceEntity = entityManager.getReference(PerformanceEntity::class.java, performanceId) - // val performanceEntity = performanceRepository.findByIdOrNull(performanceId) ?: throw PerformanceNotFoundException() + + val performanceEntity = performanceRepository.findByIdOrNull(performanceId) ?: throw PerformanceNotFoundException() val authorEntity = userRepository.findByIdOrNull(authorId) ?: throw AuthenticateException() val reviewEntity = ReviewEntity( id = "", author = authorEntity, - performanceId = performanceId, - // performance = performanceEntity, + performance = performanceEntity, title = title, content = content, rating = rating, @@ -64,7 +67,7 @@ class ReviewService( ).let { reviewRepository.save(it) } - return Review.fromEntity(reviewEntity) + return Review.fromEntity(reviewEntity, replyService.countReplies(reviewEntity.id)) } @Transactional @@ -87,7 +90,7 @@ class ReviewService( content?.let { reviewEntity.content = it } reviewEntity.updatedAt = Instant.now() reviewRepository.save(reviewEntity) - return Review.fromEntity(reviewEntity) + return Review.fromEntity(reviewEntity, replyService.countReplies(reviewEntity.id)) } @Transactional @@ -118,31 +121,31 @@ class ReviewService( } } - // @Transactional - // fun likeReview( - // user: User, - // reviewId: String, - // ) { - // val reviewEntity = reviewRepository.findByIdWithWriteLock(reviewId) ?: throw ReviewNotFoundException() - // val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() - // if (reviewEntity.reviewLikes.any { it.user.id == userEntity.id }) { - // return - // } - // val reviewLikeEntity = reviewLikeRepository.save(ReviewLikeEntity(review = reviewEntity, user = userEntity, createdAt = Instant.now(), updatedAt = Instant.now())) - // reviewEntity.reviewLikes += reviewLikeEntity - // reviewRepository.save(reviewEntity) - // } + @Transactional + fun likeReview( + userId: String, + reviewId: String, + ) { + val reviewEntity = reviewRepository.findByIdWithWriteLock(reviewId) ?: throw ReviewNotFoundException() + val userEntity = userRepository.findByIdOrNull(userId) ?: throw AuthenticateException() + if (reviewEntity.reviewLikes.any { it.user.id == userEntity.id }) { + return + } + val reviewLikeEntity = reviewLikeRepository.save(ReviewLikeEntity(review = reviewEntity, user = userEntity)) + reviewEntity.reviewLikes += reviewLikeEntity + reviewRepository.save(reviewEntity) + } - // @Transactional - // fun unlikeReview( - // user: User, - // reviewId: String, - // ) { - // val reviewEntity = reviewRepository.findByIdWithWriteLock(reviewId) ?: throw ReviewNotFoundException() - // val userEntity = userRepository.findByIdOrNull(user.id) ?: throw AuthenticateException() - // val reviewToDelete = reviewEntity.reviewLikes.find { it.user.id == userEntity.id } ?: return - // reviewEntity.reviewLikes -= reviewToDelete - // reviewLikeRepository.delete(reviewToDelete) - // reviewRepository.save(reviewEntity) - // } -} \ No newline at end of file + @Transactional + fun cancelLikeReview( + userId: String, + reviewId: String, + ) { + val reviewEntity = reviewRepository.findByIdWithWriteLock(reviewId) ?: throw ReviewNotFoundException() + val userEntity = userRepository.findByIdOrNull(userId) ?: throw AuthenticateException() + val reviewLikeToDelete = reviewEntity.reviewLikes.find { it.user.id == userEntity.id } ?: return + reviewEntity.reviewLikes -= reviewLikeToDelete + reviewLikeRepository.delete(reviewLikeToDelete) + reviewRepository.save(reviewEntity) + } +} diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt index f4361f9..771aa6e 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt @@ -32,6 +32,7 @@ class UserController( description = """ 새로운 사용자를 등록합니다. 사용자 이름, 비밀번호, 닉네임, 이메일, 전화번호를 입력받아 저장합니다. + useraname은 6~20자, password는 8~12자를 만족해야 합니다 요청이 유효하지 않은 경우 또는 사용자 이름이 중복된 경우 적절한 에러 메시지를 반환합니다. """, responses = [ @@ -194,7 +195,3 @@ data class SignInResponse( data class TokenResponse( val accessToken: String, ) - -data class SignOutRequest(val refreshToken: String) - -data class RefreshTokenRequest(val refreshToken: String) diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt index 8691045..a5096eb 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt @@ -11,6 +11,7 @@ import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* import org.springframework.transaction.annotation.Transactional +import java.time.Instant import java.util.UUID @AutoConfigureMockMvc @@ -68,7 +69,18 @@ class ReplyIntegrationTest .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it).get("accessToken").asText() } - performanceId = "sample-performance" + //테스트 용으로 아무 공연 Id를 하나 가져온다 + performanceId = + mvc.perform( + get("/api/v1/performance/search") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performances = mapper.readTree(it) + performances[0].get("id").asText() + } // 3️⃣ 리뷰 생성 (테스트용) reviewId = @@ -240,4 +252,227 @@ class ReplyIntegrationTest ).andExpect(status().`is`(401)) .andExpect(jsonPath("$.error").value("Unauthorized Access To Reply")) } + + @Test + fun `댓글을 달면 댓글수가 증가한다`() { + var reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + var targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("replyCount").asText() == "0") { "expected 0 replyCount but ${targetReview!!.get("reply").asText()}"} + + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "Great review! I totally agree."), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + + reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("replyCount").asText() == "1") { "expected 1 replyCount but ${targetReview!!.get("replyCount").asText()}"} + } + + @Test + fun `댓글은 여러 개를 달 수 있다`() { + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "Great review! I totally agree."), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "I can't stop thinking about this review! I totally agree again."), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "Again and Again and Again"), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + + val reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + val targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("replyCount").asText() == "3") { "expected 3 replyCount but ${targetReview!!.get("replyCount").asText()}"} + } + + @Test + fun `댓글을 지우면 댓글수가 줄어든다`() { + replyId = + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "Great review! I totally agree."), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("id").asText() } + + var reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + var targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("replyCount").asText() == "1") { "expected 1 replyCount but ${targetReview!!.get("replyCount").asText()}"} + + mvc.perform( + delete("/api/v1/reply/$replyId") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(204)) + + reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("replyCount").asText() == "0") { "expected 0 replyCount but ${targetReview!!.get("likeCount").asText()}"} + + } + + @Test + fun `GET을 했을 때 댓글을 최신순으로 정렬되어 반환된다`() { + val otherAccessTokens = (1..5).map { num -> + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "otherMan2$num", + "password" to "goodPassword", + "nickname" to "NICKNAME", + "phoneNumber" to "010-1234-5678", + "email" to "hacker@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "otherMan2$num", + "password" to "goodPassword", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + } + (1..5).forEach { + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer ${otherAccessTokens[it-1]}") + .content( + mapper.writeValueAsString( + mapOf("content" to "$it"), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + } + + (1..5).forEach { + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "$it"), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + } + + val reviewReplies = mvc.perform( + get("/api/v1/review/$reviewId/reply") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + .map { Instant.parse(it.get("createdAt").asText()) } + val isReviewReplySorted = reviewReplies.zipWithNext { a,b -> !a.isBefore(b) }.all {it} + assert (isReviewReplySorted) { "expected review rating sorted but not" } + + val userReplies = mvc.perform( + get("/api/v1/me/reply") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + .map { Instant.parse(it.get("createdAt").asText()) } + val isUserReplySorted = userReplies.zipWithNext { a,b -> !a.isBefore(b) }.all {it} + assert (isUserReplySorted) { "expected user reply sorted but not" } + } } diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt index 391de23..fd3f6c6 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt @@ -12,6 +12,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* import org.springframework.test.web.servlet.result.MockMvcResultHandlers.* import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* import org.springframework.transaction.annotation.Transactional +import java.time.Instant import java.util.UUID @AutoConfigureMockMvc @@ -68,7 +69,18 @@ class ReviewIntegrationTest .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it).get("accessToken").asText() } - performanceId = "sample-performance" // 가상의 공연 ID + //테스트 용으로 아무 공연 Id를 하나 가져온다 + performanceId = + mvc.perform( + get("/api/v1/performance/search") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performances = mapper.readTree(it) + performances[0].get("id").asText() + } } @Test @@ -97,7 +109,6 @@ class ReviewIntegrationTest // 4️⃣ 리뷰 조회 (성공) mvc.perform( get("/api/v1/performance/$performanceId/review") - .header("Authorization", "Bearer $accessToken"), ).andExpect(status().`is`(200)) .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) // reviewId가 포함된 객체가 존재하는지 확인 @@ -240,4 +251,101 @@ class ReviewIntegrationTest ).andExpect(status().`is`(401)) .andExpect(jsonPath("$.error").value("Unauthorized Access To Review")) } + + @Test + fun `GET을 했을 때 리뷰를 최신순으로 정렬하여 반환된다`() { + val otherAccessTokens = (1..5).map { num -> + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "otherMan$num", + "password" to "goodPassword", + "nickname" to "NICKNAME", + "phoneNumber" to "010-1234-5678", + "email" to "hacker@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "otherMan$num", + "password" to "goodPassword", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + } + + (1..5).forEach { + mvc.perform( + post("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer ${otherAccessTokens[it-1]}") + .content( + mapper.writeValueAsString( + mapOf( + "rating" to it, + "title" to "Great Performance!", + "content" to "Absolutely amazing. Highly recommend!", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + } + + (1..5).forEach { + mvc.perform( + post("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer ${accessToken}") + .content( + mapper.writeValueAsString( + mapOf( + "rating" to it, + "title" to "Great Performance!", + "content" to "Absolutely amazing. Highly recommend!", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + } + + val performanceReviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + .map { Instant.parse(it.get("createdAt").asText()) } + val isPerformanceReviewSorted = performanceReviews.zipWithNext { a,b -> !a.isBefore(b) }.all {it} + assert (isPerformanceReviewSorted) { "expected sorted performance reviews but not" } + + val userReviews = mvc.perform( + get("/api/v1/me/review") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + .map { Instant.parse(it.get("createdAt").asText()) } + val isUserReviewSorted = userReviews.zipWithNext { a,b -> !a.isBefore(b) }.all {it} + assert (isUserReviewSorted) { "expected sorted user reviews but not" } + } } diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt new file mode 100644 index 0000000..ff570be --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt @@ -0,0 +1,255 @@ +package com.wafflestudio.interpark.review + +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Transactional +class ReviewLikeIntegrationTest +@Autowired +constructor( + private val mvc: MockMvc, + private val mapper: ObjectMapper, +) { + private lateinit var accessToken: String + private lateinit var otherAccessToken: String + private lateinit var performanceId: String + private lateinit var reviewId: String + + @BeforeEach + fun setUp() { + val username = UUID.randomUUID().toString().take(8) + val password = "password123" + + // 1️⃣ 회원가입 + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + "nickname" to "reviewer", + "phoneNumber" to "010-0000-0000", + "email" to "reviewer@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + // 2️⃣ 로그인 → 토큰 획득 + accessToken = + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + //다른 사용자로도 좋아요가 되는지 체크 + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "otherUser1", + "password" to "goodPassword", + "nickname" to "otherUser1", + "phoneNumber" to "010-1234-5678", + "email" to "other@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + otherAccessToken = + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to "otherUser1", + "password" to "goodPassword", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + //테스트 용으로 아무 공연 Id를 하나 가져온다 + performanceId = + mvc.perform( + get("/api/v1/performance/search") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val performances = mapper.readTree(it) + performances[0].get("id").asText() + } + + reviewId = + mvc.perform( + post("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken") + .content( + mapper.writeValueAsString( + mapOf( + "rating" to 5, + "title" to "Great Performance!", + "content" to "Absolutely amazing. Highly recommend!", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("id").asText() } + } + + @Test + fun `좋아요를 하면 좋아요 수가 증가한다`() { + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + var reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + var targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("likeCount").asText() == "1") { "expected 1 likeCount but ${targetReview!!.get("likeCount").asText()}"} + + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $otherAccessToken") + ).andExpect(status().`is`(204)) + + reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("likeCount").asText() == "2") { "expected 2 likeCount but ${targetReview!!.get("likeCount").asText()}"} + } + + @Test + fun `한 사람이 좋아요를 여러 번 눌러도 좋아요는 1만 증가한다`() { + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + val reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + val targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("likeCount").asText() == "1") { "expected 1 likeCount but ${targetReview!!.get("likeCount").asText()}"} + } + + @Test + fun `좋아요를 취소하면 좋아요 수가 줄어든다`() { + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + mvc.perform( + delete("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + var reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + var targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("likeCount").asText() == "0") { "expected 0 likeCount but ${targetReview!!.get("likeCount").asText()}"} + + mvc.perform( + post("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $accessToken") + ).andExpect(status().`is`(204)) + + mvc.perform( + delete("/api/v1/review/$reviewId/like") + .header("Authorization", "Bearer $otherAccessToken") + ).andExpect(status().`is`(204)) + + // otherUser은 좋아요를 누르지 않았으니 취소해도 좋아요가 줄어들지 않는다 + // 좋아요를 누르지 않은 채로 취소해도 버그는 나지 않는다 + reviews = mvc.perform( + get("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $accessToken"), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$[?(@.id == '$reviewId')]").exists()) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + targetReview = reviews.find { it.get("id").asText() == reviewId } + assert(targetReview!!.get("likeCount").asText() == "1") { "expected 1 likeCount but ${targetReview!!.get("likeCount").asText()}"} + } +} From 8a846eef50039e2cd1fb0b4cb088f861e01b6768 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Fri, 24 Jan 2025 14:30:57 +0900 Subject: [PATCH 083/162] roll back 401 -> 403 when access denied --- .../security/JwtAuthenticationFilter.kt | 25 ++++++++++--------- .../user/persistence/UserIdentityEntity.kt | 2 +- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt index 3a5bf87..8bb7b6b 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt @@ -1,6 +1,5 @@ package com.wafflestudio.interpark.security -import com.wafflestudio.interpark.user.AuthenticateException import com.wafflestudio.interpark.user.UserAccessTokenUtil import com.wafflestudio.interpark.user.service.UserDetailsServiceImpl import org.springframework.security.authentication.UsernamePasswordAuthenticationToken @@ -29,19 +28,21 @@ class JwtAuthenticationFilter( val accessToken = header.split(" ")[1] // 2) UserAccessTokenUtil로 토큰 유효성 검사 - // 유효하면 userId가 반환되고, 유효하지 않으면 AuthenticateException 던짐 - val subject = userAccessTokenUtil.validateAccessToken(accessToken) ?: throw AuthenticateException() + // 유효하면 userId가 반환되고, 유효하지 않으면 null + val subject = userAccessTokenUtil.validateAccessToken(accessToken) - // 3) subject(userId)를 기반으로 DB에서 유저/권한 정보 조회 (UserDetails) - val userDetails = userDetailsService.loadUserByUserId(subject) + if (subject != null) { + // 3) subject(userId)를 기반으로 DB에서 유저/권한 정보 조회 (UserDetails) + val userDetails = userDetailsService.loadUserByUserId(subject) - // 4) Spring Security에 Authentication 등록 - val authentication = UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.authorities - ) - SecurityContextHolder.getContext().authentication = authentication + // 4) Spring Security에 Authentication 등록 + val authentication = UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.authorities + ) + SecurityContextHolder.getContext().authentication = authentication + } } // 5) 체인 계속 진행 diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt index 8252d0f..4e35567 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt @@ -24,7 +24,7 @@ class UserIdentityEntity( var role: UserRole = UserRole.USER, @Column(name = "hashed_password", nullable = false) val hashedPassword: String, - @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + @OneToMany(mappedBy = "userIdentity", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) val socialAccounts: MutableList = mutableListOf(), ) From c9e88c0b73577f56b0e663600331709e1c5ba02b Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sun, 19 Jan 2025 18:00:01 +0900 Subject: [PATCH 084/162] setup .env --- .env | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 0000000..e45ba25 --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +SPRING_DATASOURCE_URL: "jdbc:mysql://mysql-db:3306/testdb" +SPRING_DATASOURCE_USERNAME: "user" +SPRING_DATASOURCE_PASSWORD: "somepassword" \ No newline at end of file From 4bfbbd4fa53daa8ffb331e75dd3ee48f597b894f Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Fri, 24 Jan 2025 22:51:01 +0900 Subject: [PATCH 085/162] fix: Change Reservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReservationEntity 예매하면서 만들어지도록 변경 --- .../service/PerformanceEventService.kt | 2 - .../interpark/seat/SeatException.kt | 6 +++ .../seat/controller/SeatController.kt | 13 +++--- .../seat/persistence/ReservationEntity.kt | 10 +++-- .../seat/persistence/ReservationRepository.kt | 6 +-- .../seat/service/SeatCreationService.kt | 15 ------- .../interpark/seat/service/SeatService.kt | 42 ++++++++++++------- .../interpark/SeatIntegrationTest.kt | 1 - 8 files changed, 45 insertions(+), 50 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt index deb6c11..f93c0e9 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt @@ -68,8 +68,6 @@ class PerformanceEventService( performanceEventRepository.save(it) } - seatCreationService.createEmptyReservations(newPerformanceEventEntity.id) - return PerformanceEvent.fromEntity(newPerformanceEventEntity) } diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt index 89bc310..7c45d42 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt @@ -11,6 +11,12 @@ sealed class SeatException( cause: Throwable? = null, ) : DomainException(errorCode, httpStatusCode, msg, cause) +class SeatNotFoundException : SeatException( + errorCode = 0, + httpStatusCode = HttpStatus.NOT_FOUND, + msg = "Seat Not Found", +) + class ReservationNotFoundException : SeatException( errorCode = 0, httpStatusCode = HttpStatus.NOT_FOUND, diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt index 8dd87e9..6ea505d 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt @@ -22,7 +22,7 @@ class SeatController( @PathVariable performanceEventId: String, ): ResponseEntity { val seats = seatService.getAvailableSeats(performanceEventId) - return ResponseEntity.ok(GetAvailableSeatsResponse(seats.map { AvailableSeat(it.first, it.second) })) + return ResponseEntity.ok(GetAvailableSeatsResponse(seats)) } @PostMapping("/api/v1/reservation/reserve") @@ -30,7 +30,7 @@ class SeatController( @RequestBody request: ReserveSeatRequest, @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - val reservationId = seatService.reserveSeat(userDetails.getUserId(), request.reservationId) + val reservationId = seatService.reserveSeat(userDetails.getUserId(), request.performanceEventId, request.seatId) return ResponseEntity.status(200).body(ReserveSeatResponse(reservationId)) } @@ -68,17 +68,14 @@ data class BriefReservation( val performanceDate: LocalDate, val reservationDate: LocalDate, ) -data class AvailableSeat( - val reservationId: String, - val seat: Seat, -) data class GetAvailableSeatsResponse( - val availableSeats: List, + val availableSeats: List, ) data class ReserveSeatRequest( - val reservationId: String, + val performanceEventId: String, + val seatId: String, ) data class ReserveSeatResponse( diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt index 6c17a15..c857cd8 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt @@ -11,10 +11,16 @@ import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint import java.time.LocalDate @Entity -@Table(name = "reservations") +@Table( + name = "reservations", + uniqueConstraints = [ + UniqueConstraint(columnNames = ["performance_event_id", "seat_id"]) + ] +) class ReservationEntity( @Id @GeneratedValue(strategy = GenerationType.UUID) @@ -30,6 +36,4 @@ class ReservationEntity( val performanceEvent: PerformanceEventEntity, @Column(name = "reservation_date") var reservationDate: LocalDate?, - @Column(name = "reserved") - var reserved: Boolean = false, ) diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt index 826d118..376412a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt @@ -9,9 +9,5 @@ interface ReservationRepository : JpaRepository { @Query("SELECT r FROM ReservationEntity r WHERE r.user.id = :userId ORDER BY r.reservationDate DESC") fun findByUserId(userId: String): List - fun findByPerformanceEventIdAndReservedIsFalse(performanceEventId: String): List - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT r FROM ReservationEntity r WHERE r.id = :id") - fun findByIdWithWriteLock(id: String): ReservationEntity? + fun findByPerformanceEventId(performanceEventId: String): List } diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatCreationService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatCreationService.kt index cc8c923..e48691a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatCreationService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatCreationService.kt @@ -46,19 +46,4 @@ class SeatCreationService( } } } - - @Transactional - fun createEmptyReservations(performanceEventId: String) { - val performanceEventEntity = performanceEventRepository.findByIdOrNull(performanceEventId) ?: throw PerformanceEventNotFoundException() - val seats = seatRepository.findByPerformanceHall(performanceEventEntity.performanceHall) - val emptyReservations = - seats.map { - ReservationEntity( - seat = it, - performanceEvent = performanceEventEntity, - reservationDate = null, - ) - } - reservationRepository.saveAll(emptyReservations) - } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt index 8b9778f..1bf4885 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt @@ -6,13 +6,16 @@ import com.wafflestudio.interpark.seat.ReservationNotFoundException import com.wafflestudio.interpark.seat.ReservationPermissionDeniedException import com.wafflestudio.interpark.seat.ReservedAlreadyException import com.wafflestudio.interpark.seat.ReservedYetException +import com.wafflestudio.interpark.seat.SeatNotFoundException import com.wafflestudio.interpark.seat.controller.BriefReservation import com.wafflestudio.interpark.seat.controller.Reservation import com.wafflestudio.interpark.seat.controller.Seat +import com.wafflestudio.interpark.seat.persistence.ReservationEntity import com.wafflestudio.interpark.seat.persistence.ReservationRepository import com.wafflestudio.interpark.seat.persistence.SeatRepository import com.wafflestudio.interpark.user.AuthenticateException import com.wafflestudio.interpark.user.persistence.UserRepository +import org.springframework.dao.DataIntegrityViolationException import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -21,33 +24,42 @@ import java.time.LocalDate @Service class SeatService( private val reservationRepository: ReservationRepository, + private val seatRepository: SeatRepository, private val performanceEventRepository: PerformanceEventRepository, private val userRepository: UserRepository, ) { @Transactional - fun getAvailableSeats(performanceEventId: String): List> { - performanceEventRepository.findByIdOrNull(performanceEventId) ?: throw PerformanceEventNotFoundException() - val availableReservations = reservationRepository.findByPerformanceEventIdAndReservedIsFalse(performanceEventId) - val availableSeats = availableReservations.map { Pair(it.id!!, Seat.fromEntity(it.seat)) } + fun getAvailableSeats(performanceEventId: String): List { + val performanceEvent = performanceEventRepository.findByIdOrNull(performanceEventId) ?: throw PerformanceEventNotFoundException() + val reservedSeats = reservationRepository.findByPerformanceEventId(performanceEventId).map { it.seat } + val allSeats = seatRepository.findByPerformanceHall(performanceEvent.performanceHall) + val availableSeats = allSeats.filter { it !in reservedSeats }.map { Seat.fromEntity(it) } return availableSeats } @Transactional fun reserveSeat( userId: String, - reservationId: String, + performanceEventId: String, + seatId: String, ): String { val targetUser = userRepository.findByIdOrNull(userId) ?: throw AuthenticateException() - val targetReservation = reservationRepository.findByIdWithWriteLock(reservationId) ?: throw ReservationNotFoundException() - - if (targetReservation.reserved) throw ReservedAlreadyException() + val targetSeat = seatRepository.findByIdOrNull(seatId) ?: throw SeatNotFoundException() + val targetPerformanceEvent = performanceEventRepository.findByIdOrNull(performanceEventId) ?: throw PerformanceEventNotFoundException() + val newReservation = ReservationEntity( + user = targetUser, + seat = targetSeat, + performanceEvent = targetPerformanceEvent, + reservationDate = LocalDate.now(), + ) - targetReservation.user = targetUser - targetReservation.reserved = true - targetReservation.reservationDate = LocalDate.now() - reservationRepository.save(targetReservation) + val savedReservationId = try { + reservationRepository.save(newReservation).id!! + } catch (e: DataIntegrityViolationException) { + throw ReservedAlreadyException() + } - return reservationId + return savedReservationId } @Transactional @@ -107,8 +119,6 @@ class SeatService( throw ReservationPermissionDeniedException() } - reservationEntity.user = null - reservationEntity.reservationDate = null - reservationEntity.reserved = false + reservationRepository.delete(reservationEntity) } } diff --git a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt index d4dfcb8..2b50ebd 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt @@ -24,7 +24,6 @@ class SeatIntegrationTest constructor( private val mvc: MockMvc, private val mapper: ObjectMapper, - private val seatCreationService: SeatCreationService, ) { private lateinit var accessToken: String private lateinit var performanceEventId: String From 3a15d7c1dd88980dc5d794a8a18a5d5c9cb09b7f Mon Sep 17 00:00:00 2001 From: grantzile <88519798+Grantzile@users.noreply.github.com> Date: Sat, 25 Jan 2025 13:03:23 +0900 Subject: [PATCH 086/162] CI: fixed CI (#23) * hotfix: change CI/CD jdk to 23 * hotfix: use compose in CI * hotfix: fix ci * hotfix: reset ci * fix: build test on all branch --- .github/workflows/build-test.yaml | 6 +++--- src/test/resources/application.yaml | 27 +++++++++++++-------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index 3142465..c9190f1 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -4,7 +4,7 @@ on: push: branches: - main - - release-dev + - '*' jobs: deploy: @@ -29,11 +29,11 @@ jobs: - name: Checkout Repository uses: actions/checkout@v4 - - name: Set up JDK 17 + - name: Set up JDK 23 uses: actions/setup-java@v3 with: distribution: 'temurin' - java-version: '17' + java-version: '23' - name: Build with Gradle run: | diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 1c6362b..5109165 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -1,20 +1,19 @@ spring: datasource: - url: 'jdbc:h2:mem:testdb;MODE=MySQL' - driver-class-name: org.h2.Driver - username: sa - password: 1234 + url: 'jdbc:mysql://localhost:3306/testdb' + driver-class-name: com.mysql.cj.jdbc.Driver + username: user + password: somepassword jpa: - database-platform: org.hibernate.dialect.H2Dialect hibernate: - ddl-auto: create + ddl-auto: create-drop + show-sql: true properties: hibernate: - show_sql: true - format_sql: true - dialect: org.hibernate.dialect.H2Dialect - defer-datasource-initialization: true - h2: - console: - enabled: true - path: /h2-console \ No newline at end of file + show_sql: false + profiles: + active: dev + +cache: + expire-after-write: 1m + maximum-size: 100 From dda436fde1d8c7ea4c68baa4a4f67cf499c34cdb Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sat, 25 Jan 2025 18:46:11 +0900 Subject: [PATCH 087/162] =?UTF-8?q?fix:=20error=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SeatService에서 DataIntegrityViolationException을 핸들링하기 위해 saveAndFlush를 사용했다. 기존에는 save가 트랜잭션이 끝나고 완료되어서 오류가 핸들링이 안 되었었다 --- .../interpark/seat/SeatException.kt | 6 ++++ .../seat/controller/SeatController.kt | 2 +- .../seat/persistence/ReservationEntity.kt | 4 +-- .../interpark/seat/service/SeatService.kt | 4 ++- .../interpark/SeatIntegrationTest.kt | 34 +++++++++++++------ 5 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt index 7c45d42..adb3bcb 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt @@ -17,6 +17,12 @@ class SeatNotFoundException : SeatException( msg = "Seat Not Found", ) +class WrongSeatException : SeatException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "Wrong Seat", +) + class ReservationNotFoundException : SeatException( errorCode = 0, httpStatusCode = HttpStatus.NOT_FOUND, diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt index 6ea505d..41bd855 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt @@ -31,7 +31,7 @@ class SeatController( @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { val reservationId = seatService.reserveSeat(userDetails.getUserId(), request.performanceEventId, request.seatId) - return ResponseEntity.status(200).body(ReserveSeatResponse(reservationId)) + return ResponseEntity.status(201).body(ReserveSeatResponse(reservationId)) } @GetMapping("/api/v1/me/reservation") diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt index c857cd8..cf38d26 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt @@ -18,7 +18,7 @@ import java.time.LocalDate @Table( name = "reservations", uniqueConstraints = [ - UniqueConstraint(columnNames = ["performance_event_id", "seat_id"]) + UniqueConstraint(name = "UniquePerfEvAndSeat", columnNames = ["performance_event_id", "seat_id"]) ] ) class ReservationEntity( @@ -32,7 +32,7 @@ class ReservationEntity( @JoinColumn(name = "seat_id", nullable = false) val seat: SeatEntity, @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "performance_event_id") + @JoinColumn(name = "performance_event_id", nullable = false) val performanceEvent: PerformanceEventEntity, @Column(name = "reservation_date") var reservationDate: LocalDate?, diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt index 1bf4885..2edd0fb 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt @@ -7,6 +7,7 @@ import com.wafflestudio.interpark.seat.ReservationPermissionDeniedException import com.wafflestudio.interpark.seat.ReservedAlreadyException import com.wafflestudio.interpark.seat.ReservedYetException import com.wafflestudio.interpark.seat.SeatNotFoundException +import com.wafflestudio.interpark.seat.WrongSeatException import com.wafflestudio.interpark.seat.controller.BriefReservation import com.wafflestudio.interpark.seat.controller.Reservation import com.wafflestudio.interpark.seat.controller.Seat @@ -46,6 +47,7 @@ class SeatService( val targetUser = userRepository.findByIdOrNull(userId) ?: throw AuthenticateException() val targetSeat = seatRepository.findByIdOrNull(seatId) ?: throw SeatNotFoundException() val targetPerformanceEvent = performanceEventRepository.findByIdOrNull(performanceEventId) ?: throw PerformanceEventNotFoundException() + if(targetSeat.performanceHall != targetPerformanceEvent.performanceHall) { throw WrongSeatException() } val newReservation = ReservationEntity( user = targetUser, seat = targetSeat, @@ -54,7 +56,7 @@ class SeatService( ) val savedReservationId = try { - reservationRepository.save(newReservation).id!! + reservationRepository.saveAndFlush(newReservation).id!! } catch (e: DataIntegrityViolationException) { throw ReservedAlreadyException() } diff --git a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt index 2b50ebd..e649cfe 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt @@ -18,7 +18,6 @@ import java.util.UUID @AutoConfigureMockMvc @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Transactional class SeatIntegrationTest @Autowired constructor( @@ -97,7 +96,7 @@ constructor( @Test fun `좌석을 예매할 수 있고 예매되면 더 이상 예매되지 못한다`() { - val reservationId = mvc.perform( + val seatId = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) .andReturn() @@ -105,27 +104,34 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { val availableSeats = mapper.readTree(it).get("availableSeats") - availableSeats[0].get("reservationId").asText() + availableSeats[0].get("id").asText() } mvc.perform( post("/api/v1/reservation/reserve") .content( mapper.writeValueAsString( mapOf( - "reservationId" to reservationId, + "performanceEventId" to performanceEventId, + "seatId" to seatId, ), ), ) .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) - ).andExpect(status().`is`(200)) + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("reservationId").asText() } + //한번 예매된 좌석은 예매되지 않는다 mvc.perform( post("/api/v1/reservation/reserve") .content( mapper.writeValueAsString( mapOf( - "reservationId" to reservationId, + "performanceEventId" to performanceEventId, + "seatId" to seatId, ), ), ) @@ -136,7 +142,7 @@ constructor( @Test fun `본인의 예매내역을 확인할 수 있다`() { - val reservationId = mvc.perform( + val seatId = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) .andReturn() @@ -144,20 +150,26 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { val availableSeats = mapper.readTree(it).get("availableSeats") - availableSeats[0].get("reservationId").asText() + availableSeats[0].get("id").asText() } - mvc.perform( + val reservationId = mvc.perform( post("/api/v1/reservation/reserve") .content( mapper.writeValueAsString( mapOf( - "reservationId" to reservationId, + "performanceEventId" to performanceEventId, + "seatId" to seatId, ), ), ) .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) - ).andExpect(status().`is`(200)) + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("reservationId").asText() } + val myReservations = mvc.perform( get("/api/v1/me/reservation") From 8118dd6a0a07d30e939308fc09b8ea7e59b84f8d Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sun, 26 Jan 2025 01:07:54 +0900 Subject: [PATCH 088/162] fix: Seat Test --- .../interpark/SeatIntegrationTest.kt | 74 +++++++++++-------- .../interpark/SimultaneousTest.kt | 27 ++++--- 2 files changed, 59 insertions(+), 42 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt index e649cfe..8db3c6a 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt @@ -170,7 +170,6 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it).get("reservationId").asText() } - val myReservations = mvc.perform( get("/api/v1/me/reservation") .header("Authorization", "Bearer $accessToken") @@ -187,7 +186,7 @@ constructor( @Test fun `본인의 예매를 자세히 볼 수 있다`() { - val reservationId = mvc.perform( + val seatId = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) .andReturn() @@ -195,22 +194,26 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { val availableSeats = mapper.readTree(it).get("availableSeats") - availableSeats[0].get("reservationId").asText() + availableSeats[0].get("id").asText() } - mvc.perform( + val reservationId = mvc.perform( post("/api/v1/reservation/reserve") .content( mapper.writeValueAsString( mapOf( - "reservationId" to reservationId, + "performanceEventId" to performanceEventId, + "seatId" to seatId, ), ), ) .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) - ).andExpect(status().`is`(200)) - - val myReservationIds = mvc.perform( + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("reservationId").asText() } + mvc.perform( get("/api/v1/reservation/detail/$reservationId") .header("Authorization", "Bearer $accessToken") ).andExpect(status().`is`(200)) @@ -218,7 +221,7 @@ constructor( @Test fun `좌석을 취소할 수 있다`() { - val reservationId = mvc.perform( + val seatId = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) .andReturn() @@ -226,20 +229,25 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { val availableSeats = mapper.readTree(it).get("availableSeats") - availableSeats[0].get("reservationId").asText() + availableSeats[0].get("id").asText() } - mvc.perform( + val reservationId = mvc.perform( post("/api/v1/reservation/reserve") .content( mapper.writeValueAsString( mapOf( - "reservationId" to reservationId, + "performanceEventId" to performanceEventId, + "seatId" to seatId, ), ), ) .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) - ).andExpect(status().`is`(200)) + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("reservationId").asText() } mvc.perform( post("/api/v1/reservation/cancel") @@ -260,18 +268,19 @@ constructor( .content( mapper.writeValueAsString( mapOf( - "reservationId" to reservationId, + "performanceEventId" to performanceEventId, + "seatId" to seatId, ), ), ) .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) - ).andExpect(status().`is`(200)) + ).andExpect(status().`is`(201)) } @Test fun `다른 사람은 좌석을 취소할 수 없다`() { - val reservationId = mvc.perform( + val seatId = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) .andReturn() @@ -279,8 +288,26 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { val availableSeats = mapper.readTree(it).get("availableSeats") - availableSeats[0].get("reservationId").asText() + availableSeats[0].get("id").asText() } + val reservationId = mvc.perform( + post("/api/v1/reservation/reserve") + .content( + mapper.writeValueAsString( + mapOf( + "performanceEventId" to performanceEventId, + "seatId" to seatId, + ), + ), + ) + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("reservationId").asText() } + mvc.perform( post("/api/v1/local/signup") @@ -316,19 +343,6 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it).get("accessToken").asText() } - mvc.perform( - post("/api/v1/reservation/reserve") - .content( - mapper.writeValueAsString( - mapOf( - "reservationId" to reservationId, - ), - ), - ) - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - ).andExpect(status().`is`(200)) - mvc.perform( post("/api/v1/reservation/cancel") .content( diff --git a/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt index 445c67a..f9ad629 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt @@ -85,7 +85,7 @@ constructor( performanceEvents[0].get("id").asText() } - val reservationId = mvc.perform( + val seatId = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) .andReturn() @@ -93,7 +93,7 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { val availableSeats = mapper.readTree(it).get("availableSeats") - availableSeats[0].get("reservationId").asText() + availableSeats[0].get("id").asText() } val results = mutableListOf() @@ -106,7 +106,8 @@ constructor( .content( mapper.writeValueAsString( mapOf( - "reservationId" to reservationId, + "performanceEventId" to performanceEventId, + "seatId" to seatId, ), ), ) @@ -114,7 +115,7 @@ constructor( .contentType(MediaType.APPLICATION_JSON) ).andReturn().response.status results.add(responseStatus) - if (responseStatus == 200) { successCnt.incrementAndGet() } + if (responseStatus == 201) { successCnt.incrementAndGet() } if (responseStatus == 409) { conflictCnt.incrementAndGet() } } } @@ -182,7 +183,7 @@ constructor( performanceEvents[0].get("id").asText() } - val reservationId = mvc.perform( + val seatId = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) .andReturn() @@ -190,7 +191,7 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { val availableSeats = mapper.readTree(it).get("availableSeats") - availableSeats[0].get("reservationId").asText() + availableSeats[0].get("id").asText() } val results = mutableListOf() @@ -203,7 +204,8 @@ constructor( .content( mapper.writeValueAsString( mapOf( - "reservationId" to reservationId, + "performanceEventId" to performanceEventId, + "seatId" to seatId, ), ), ) @@ -211,7 +213,7 @@ constructor( .contentType(MediaType.APPLICATION_JSON) ).andReturn().response.status results.add(responseStatus) - if (responseStatus == 200) { successCnt.incrementAndGet() } + if (responseStatus == 201) { successCnt.incrementAndGet() } if (responseStatus == 409) { conflictCnt.incrementAndGet() } } } @@ -279,7 +281,7 @@ constructor( performanceEvents[0].get("id").asText() } - val reservationId = mvc.perform( + val seatId = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) .andReturn() @@ -287,7 +289,7 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { val availableSeats = mapper.readTree(it).get("availableSeats") - (1..10).map { availableSeats[it].get("reservationId").asText() } + (1..10).map {availableSeats[it].get("id").asText() } } val results = mutableListOf() @@ -300,7 +302,8 @@ constructor( .content( mapper.writeValueAsString( mapOf( - "reservationId" to reservationId[it], + "performanceEventId" to performanceEventId, + "seatId" to seatId[it], ), ), ) @@ -308,7 +311,7 @@ constructor( .contentType(MediaType.APPLICATION_JSON) ).andReturn().response.status results.add(responseStatus) - if (responseStatus == 200) { successCnt.incrementAndGet() } + if (responseStatus == 201) { successCnt.incrementAndGet() } if (responseStatus == 409) { conflictCnt.incrementAndGet() } } } From 7900d2613f0ef7ba7a9a540b3d4b256797464867 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sun, 26 Jan 2025 01:13:11 +0900 Subject: [PATCH 089/162] fix: test conflict resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 테스트에서 같은 username을 쓰던 부분을 수정 --- .../kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt index 8db3c6a..f8c70a6 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt @@ -314,7 +314,7 @@ constructor( .content( mapper.writeValueAsString( mapOf( - "username" to "correct2", + "username" to "seatTest2", "password" to "12345678", "nickname" to "examplename", "phoneNumber" to "010-0000-0000", @@ -331,7 +331,7 @@ constructor( .content( mapper.writeValueAsString( mapOf( - "username" to "correct2", + "username" to "seatTest2", "password" to "12345678", ), ), From 1c7c0c75c519706bd15b173c0973edf171c94b3b Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sun, 26 Jan 2025 01:33:00 +0900 Subject: [PATCH 090/162] feat: Seat Api Test --- src/test/resources/SeatApi.http | 34 +++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/test/resources/SeatApi.http b/src/test/resources/SeatApi.http index f102ad7..796570d 100644 --- a/src/test/resources/SeatApi.http +++ b/src/test/resources/SeatApi.http @@ -24,13 +24,39 @@ GET http://localhost:80/api/v1/performance/search Accept: application/json ### event 찾기 -GET http://localhost:80/api/v1/performance-event/e1656490-a5be-446e-b119-d8efa393dd05/2025-01-11 -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkODUyNjcwMy0zM2Y2LTRmM2ItYTdhMC1mOGZlOGQ3NTcxNDAiLCJpYXQiOjE3MzcyNzcyMjQsImV4cCI6MTczNzI3ODEyNH0.KObDWrRBDRZU-iNyFHHherkbxIjigmjpVn-Iv5Lx3yY +GET http://localhost:80/api/v1/performance-event/e3516b5b-a157-4fd6-9b0a-81fc1de48798/2024-11-29 +Accept: application/json ### event 받기 GET http://localhost:80/api/v1/performance-event -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkODUyNjcwMy0zM2Y2LTRmM2ItYTdhMC1mOGZlOGQ3NTcxNDAiLCJpYXQiOjE3MzcyNzcyMjQsImV4cCI6MTczNzI3ODEyNH0.KObDWrRBDRZU-iNyFHHherkbxIjigmjpVn-Iv5Lx3yY ### 가능한 좌석 받기 -GET http://localhost:80/api/v1/seat/eade9900-a141-4532-bd2c-c362ceb7c8c0/available +GET http://localhost:80/api/v1/seat/0a0684f4-e69e-40bb-9590-eab0616148be/available Accept: application/json + +### 예매하기 +POST http://localhost/api/v1/reservation/reserve +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJjMmE5Mjc0Ny0zOGRlLTRjNmQtOWNlYi00MTUyYmZiMzJjNGEiLCJpYXQiOjE3Mzc4MjIwNzMsImV4cCI6MTczNzgyMjk3M30.FV6LZCg4qkJ1JVkOiHdhpgIWur4bAxzr0ZsQ9Pg9BRo +Content-Type: application/json + +{ + "performanceEventId": "0a0684f4-e69e-40bb-9590-eab0616148be", + "seatId": "fa431f1d-200e-4ec6-8da6-b24d030fd14a" +} + +### 예매정보 확인하기 +GET http://localhost/api/v1/me/reservation +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJjMmE5Mjc0Ny0zOGRlLTRjNmQtOWNlYi00MTUyYmZiMzJjNGEiLCJpYXQiOjE3Mzc4MjIwNzMsImV4cCI6MTczNzgyMjk3M30.FV6LZCg4qkJ1JVkOiHdhpgIWur4bAxzr0ZsQ9Pg9BRo + +### 예매 취소하기 +POST http://localhost/api/v1/reservation/cancel +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJjMmE5Mjc0Ny0zOGRlLTRjNmQtOWNlYi00MTUyYmZiMzJjNGEiLCJpYXQiOjE3Mzc4MjIwNzMsImV4cCI6MTczNzgyMjk3M30.FV6LZCg4qkJ1JVkOiHdhpgIWur4bAxzr0ZsQ9Pg9BRo +Content-Type: application/json + +{ + "reservationId": "825573d0-3406-464a-84ad-dd682a5cd5a4" +} + +### 예매정보 확인하기 +GET http://localhost/api/v1/me/reservation +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJjMmE5Mjc0Ny0zOGRlLTRjNmQtOWNlYi00MTUyYmZiMzJjNGEiLCJpYXQiOjE3Mzc4MjIwNzMsImV4cCI6MTczNzgyMjk3M30.FV6LZCg4qkJ1JVkOiHdhpgIWur4bAxzr0ZsQ9Pg9BRo From d725f93968ce2d52cb5dec42b31c4f6af21b68eb Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sun, 26 Jan 2025 01:49:08 +0900 Subject: [PATCH 091/162] =?UTF-8?q?fix:=20=EC=98=88=EB=A7=A4=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20POST=20->=20DELETE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../seat/controller/SeatController.kt | 11 ++++----- .../interpark/SeatIntegrationTest.kt | 18 ++------------- src/test/resources/SeatApi.http | 23 ++++++++----------- 3 files changed, 15 insertions(+), 37 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt index 41bd855..213a6ad 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt @@ -6,6 +6,7 @@ import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.controller.UserDetailsImpl import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -51,12 +52,12 @@ class SeatController( return ResponseEntity.status(200).body(GetReservedSeatDetailResponse(reservationDetail)) } - @PostMapping("/api/v1/reservation/cancel") + @DeleteMapping("/api/v1/reservation/{reservationId}") fun cancelReservedSeat( - @RequestBody request: CancelReserveSeatRequest, + @PathVariable reservationId: String, @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - seatService.cancelReservedSeat(userDetails.getUserId(), request.reservationId) + seatService.cancelReservedSeat(userDetails.getUserId(), reservationId) return ResponseEntity.noContent().build() } } @@ -89,7 +90,3 @@ data class GetMyReservationsResponse( data class GetReservedSeatDetailResponse( val reservedSeat: Reservation ) - -data class CancelReserveSeatRequest( - val reservationId: String, -) diff --git a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt index f8c70a6..64ed498 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt @@ -250,14 +250,7 @@ constructor( .let { mapper.readTree(it).get("reservationId").asText() } mvc.perform( - post("/api/v1/reservation/cancel") - .content( - mapper.writeValueAsString( - mapOf( - "reservationId" to reservationId, - ), - ), - ) + delete("/api/v1/reservation/$reservationId") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) ).andExpect(status().`is`(204)) @@ -344,14 +337,7 @@ constructor( .let { mapper.readTree(it).get("accessToken").asText() } mvc.perform( - post("/api/v1/reservation/cancel") - .content( - mapper.writeValueAsString( - mapOf( - "reservationId" to reservationId, - ), - ), - ) + delete("/api/v1/reservation/$reservationId") .header("Authorization", "Bearer $otherAccessToken") .contentType(MediaType.APPLICATION_JSON) ).andExpect(status().`is`(403)) diff --git a/src/test/resources/SeatApi.http b/src/test/resources/SeatApi.http index 796570d..34efbbc 100644 --- a/src/test/resources/SeatApi.http +++ b/src/test/resources/SeatApi.http @@ -24,39 +24,34 @@ GET http://localhost:80/api/v1/performance/search Accept: application/json ### event 찾기 -GET http://localhost:80/api/v1/performance-event/e3516b5b-a157-4fd6-9b0a-81fc1de48798/2024-11-29 +GET http://localhost:80/api/v1/performance-event/e3c2dd31-d867-4279-8a3b-869829f134f4/2025-05-11 Accept: application/json ### event 받기 GET http://localhost:80/api/v1/performance-event ### 가능한 좌석 받기 -GET http://localhost:80/api/v1/seat/0a0684f4-e69e-40bb-9590-eab0616148be/available +GET http://localhost:80/api/v1/seat/6b5b9259-d396-49fa-bb9c-27abc6094c36/available Accept: application/json ### 예매하기 POST http://localhost/api/v1/reservation/reserve -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJjMmE5Mjc0Ny0zOGRlLTRjNmQtOWNlYi00MTUyYmZiMzJjNGEiLCJpYXQiOjE3Mzc4MjIwNzMsImV4cCI6MTczNzgyMjk3M30.FV6LZCg4qkJ1JVkOiHdhpgIWur4bAxzr0ZsQ9Pg9BRo +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI5MzJkYjdkMC1jNWUzLTRlNzUtODY5Ni1iZTlkZWYxMGQzMDciLCJpYXQiOjE3Mzc4MjM2MDUsImV4cCI6MTczNzgyNDUwNX0.obnMoxlszxQwkM6Ltg-9Rsd2QGu0qJckejaMp2eqs-E Content-Type: application/json { - "performanceEventId": "0a0684f4-e69e-40bb-9590-eab0616148be", - "seatId": "fa431f1d-200e-4ec6-8da6-b24d030fd14a" + "performanceEventId": "6b5b9259-d396-49fa-bb9c-27abc6094c36", + "seatId": "fd9a1f0f-56d8-4d2b-8ad4-47e70b39fc0c" } ### 예매정보 확인하기 GET http://localhost/api/v1/me/reservation -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJjMmE5Mjc0Ny0zOGRlLTRjNmQtOWNlYi00MTUyYmZiMzJjNGEiLCJpYXQiOjE3Mzc4MjIwNzMsImV4cCI6MTczNzgyMjk3M30.FV6LZCg4qkJ1JVkOiHdhpgIWur4bAxzr0ZsQ9Pg9BRo +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI5MzJkYjdkMC1jNWUzLTRlNzUtODY5Ni1iZTlkZWYxMGQzMDciLCJpYXQiOjE3Mzc4MjM2MDUsImV4cCI6MTczNzgyNDUwNX0.obnMoxlszxQwkM6Ltg-9Rsd2QGu0qJckejaMp2eqs-E ### 예매 취소하기 -POST http://localhost/api/v1/reservation/cancel -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJjMmE5Mjc0Ny0zOGRlLTRjNmQtOWNlYi00MTUyYmZiMzJjNGEiLCJpYXQiOjE3Mzc4MjIwNzMsImV4cCI6MTczNzgyMjk3M30.FV6LZCg4qkJ1JVkOiHdhpgIWur4bAxzr0ZsQ9Pg9BRo -Content-Type: application/json - -{ - "reservationId": "825573d0-3406-464a-84ad-dd682a5cd5a4" -} +DELETE http://localhost/api/v1/reservation/7e5d1f20-dfad-4978-b1d5-2f06da8479ce +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI5MzJkYjdkMC1jNWUzLTRlNzUtODY5Ni1iZTlkZWYxMGQzMDciLCJpYXQiOjE3Mzc4MjM2MDUsImV4cCI6MTczNzgyNDUwNX0.obnMoxlszxQwkM6Ltg-9Rsd2QGu0qJckejaMp2eqs-E ### 예매정보 확인하기 GET http://localhost/api/v1/me/reservation -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJjMmE5Mjc0Ny0zOGRlLTRjNmQtOWNlYi00MTUyYmZiMzJjNGEiLCJpYXQiOjE3Mzc4MjIwNzMsImV4cCI6MTczNzgyMjk3M30.FV6LZCg4qkJ1JVkOiHdhpgIWur4bAxzr0ZsQ9Pg9BRo +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI5MzJkYjdkMC1jNWUzLTRlNzUtODY5Ni1iZTlkZWYxMGQzMDciLCJpYXQiOjE3Mzc4MjM2MDUsImV4cCI6MTczNzgyNDUwNX0.obnMoxlszxQwkM6Ltg-9Rsd2QGu0qJckejaMp2eqs-E From 1d63233640827ba5886daaff284ffeedfdebb094 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus <153504213+ChungPlusPlus@users.noreply.github.com> Date: Sun, 26 Jan 2025 18:52:39 +0900 Subject: [PATCH 092/162] =?UTF-8?q?Reservation=20=EC=88=98=EC=A0=95=20(#25?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * merge test - generate performancehallentity.kt * feat: searchPerformance * chore: 도커 컴포즈 dockerfile 작성해서 도커 이미지 잘 만들어지고 http 파일로 테스트까지 가능한 상태입니다 * modify searchPerformance * add: docker-compose * modify build.gradle.kts * add wildcard import in UserService.kt * change Performance attribute 'genre' to 'category' * change category to ENUM Type * add some Init Datas * add some Init Datas * assign posterimageURL * add: Entity 엔티티 작업중 * assign posterimageURL * add: SeatService 임시저장하겠습니다 * feat: SeatService 좌석 확인, 예매, 예매 취소 기능 구현 아직 동시성 처리 안됐습니다 * feat: Add Controller 컨트롤러 추가했습니다 곧 테스트 코드 추가해보겠습니다 * modify searchPerformance, GET요청은 RequestBody를 사용할 수 없다고 하여 다시 RequestParam을 사용하는 원래 방식으로 변경 * feat: SeatCreation performanceHallId와 type을 받아 좌석엔티티들을 자동으로 생성하는 서비스 함수와 performanceEventId를 받아 빈 예약 가능 엔티티들을 자동을 생성하는 서비스를 만들었습니다 추후 createPerformanceHall과 createPerformanceEvent함수를 수정해서 과정을 더 자동화할 수 있을 것 같습니다 * add: SeatIntegrationTest SeatIntegrationTest 만들었습니다 테스트 전부 통과하며 빌드 성공합니다 다만 테스트에 mysql 데이터베이스가 쓰이며 초기화가 자동으로 되지 않아 문제가 생기니 이는 개선이 필요합니다 * chore: use h2 while test application.yaml 파일을 테스트 코드를 위해 하나 추가해줬습니다 테스트코드에서 @Transactional까지 사용한다면 테스트 코드가 서로 영향을 끼치지 않게 됩니다 * fix: Seat Reservation reservation 바뀐 정보 반영하도록 save 사용했고, Detail확인하는 영역은 컨트롤러까지 분리했습니다 * style: seat ktlint seat폴더의 파일들에 ktlint 적용 * modify MakeFile * 공연 검색 기능 구현 * fromEntity parameter change: receive Entities -> receive DTOs * modify searchPerformance endpoint URL * chore: makefile OS 따라서 다르게 작동하게 수정 * 공연 1개 상세 정보 반환 * minor modify: change how null type is handled * 초기 데이터 수정, 공연이벤트는 일단 공연 기간의 첫날과 마지막날만 생성하였음 * modify DataInitializer, SeatIntegrationTest * add PerformanceDetail Uri * make PerformanceItegrationTest.kt * feat: 예매 동시성 동시성 처리 완료 동시성 처리는 잘 되지만, 테스트 코드 작성이 많이 까다로움 * feat: Get My Reservation Api 유저가 로그인한채로 본인의 예약 목록을 확인할 수 있는 기능 추가 * feat: Seat Get TestCode Get API가 정상작동하는지 테스트코드 추가 * modify SearchPerformanceResponse * check auth for create, delete performance * check auth for create, delete performanceEvent, performanceHall * add posterUri attr to BriefPerformanceDetail * minor change * feat: 예매목록 Brief로 주기 기존에 id만 주던 것을 Brief 데이터의 리스트를 주는 것으로 수정 signin을 할 때 User도 같이 반환하도록 수정 * fix: 로그아웃, 토큰 재발급 버그 수정, 엔드포인트 수정 * add UserIdentityNotFoundException * modify PerformanceDurtion * feat: 공연장 생성시 좌석도 함께 생성 * feat: seat 초기화 적용, 버그 수정 PerformanceHall이 생성될 때 Seat을 같이 생성, PerformanceEvent가 생성될 때 Reservation을 같이 생성하도록 변경 * feat: Find PerformanceEvent PerformanceId와 LocalDate로부터 PerformanceEventId를 반환하도록 추가 잘못된 PerformanceEventId로 빈좌석정보를 확인했을 때 에러 반환 * chore: .env 무시 * feat: Reinforce Simultaneous test code 동시에 여러 사람의 접근이 있어도 통과하는지 테스트 코드 추가 동시에 여러 사람이 서로 다른 좌석에 접근할 때 테스트 코드 추가 * feat: Review/Reply 연결 review와 reply 마무리 * comment: user 설명 추가 username과 password의 조건 설명 추가 * chore: 필요없는 코드 지우기 * feat: Sort Reviews and Replies GET을 통해 리뷰나 댓글을 조회할 때 최신순으로 반환한다 * style: add new line at EOF * hotfix: test 안되던 오류 수정 * fix: Change Reservation ReservationEntity 예매하면서 만들어지도록 변경 * fix: error 핸들링 SeatService에서 DataIntegrityViolationException을 핸들링하기 위해 saveAndFlush를 사용했다. 기존에는 save가 트랜잭션이 끝나고 완료되어서 오류가 핸들링이 안 되었었다 * fix: Seat Test * fix: test conflict resolve 테스트에서 같은 username을 쓰던 부분을 수정 * feat: Seat Api Test * fix: 예매 취소 POST -> DELETE --------- Co-authored-by: Dohyeon Kim --- .../service/PerformanceEventService.kt | 2 - .../interpark/seat/SeatException.kt | 12 ++ .../seat/controller/SeatController.kt | 26 ++-- .../seat/persistence/ReservationEntity.kt | 12 +- .../seat/persistence/ReservationRepository.kt | 6 +- .../seat/service/SeatCreationService.kt | 15 -- .../interpark/seat/service/SeatService.kt | 44 +++--- .../interpark/SeatIntegrationTest.kt | 129 ++++++++++-------- .../interpark/SimultaneousTest.kt | 27 ++-- src/test/resources/SeatApi.http | 29 +++- 10 files changed, 169 insertions(+), 133 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt index deb6c11..f93c0e9 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceEventService.kt @@ -68,8 +68,6 @@ class PerformanceEventService( performanceEventRepository.save(it) } - seatCreationService.createEmptyReservations(newPerformanceEventEntity.id) - return PerformanceEvent.fromEntity(newPerformanceEventEntity) } diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt index 89bc310..adb3bcb 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/SeatException.kt @@ -11,6 +11,18 @@ sealed class SeatException( cause: Throwable? = null, ) : DomainException(errorCode, httpStatusCode, msg, cause) +class SeatNotFoundException : SeatException( + errorCode = 0, + httpStatusCode = HttpStatus.NOT_FOUND, + msg = "Seat Not Found", +) + +class WrongSeatException : SeatException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "Wrong Seat", +) + class ReservationNotFoundException : SeatException( errorCode = 0, httpStatusCode = HttpStatus.NOT_FOUND, diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt index 8dd87e9..213a6ad 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt @@ -6,6 +6,7 @@ import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.controller.UserDetailsImpl import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -22,7 +23,7 @@ class SeatController( @PathVariable performanceEventId: String, ): ResponseEntity { val seats = seatService.getAvailableSeats(performanceEventId) - return ResponseEntity.ok(GetAvailableSeatsResponse(seats.map { AvailableSeat(it.first, it.second) })) + return ResponseEntity.ok(GetAvailableSeatsResponse(seats)) } @PostMapping("/api/v1/reservation/reserve") @@ -30,8 +31,8 @@ class SeatController( @RequestBody request: ReserveSeatRequest, @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - val reservationId = seatService.reserveSeat(userDetails.getUserId(), request.reservationId) - return ResponseEntity.status(200).body(ReserveSeatResponse(reservationId)) + val reservationId = seatService.reserveSeat(userDetails.getUserId(), request.performanceEventId, request.seatId) + return ResponseEntity.status(201).body(ReserveSeatResponse(reservationId)) } @GetMapping("/api/v1/me/reservation") @@ -51,12 +52,12 @@ class SeatController( return ResponseEntity.status(200).body(GetReservedSeatDetailResponse(reservationDetail)) } - @PostMapping("/api/v1/reservation/cancel") + @DeleteMapping("/api/v1/reservation/{reservationId}") fun cancelReservedSeat( - @RequestBody request: CancelReserveSeatRequest, + @PathVariable reservationId: String, @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { - seatService.cancelReservedSeat(userDetails.getUserId(), request.reservationId) + seatService.cancelReservedSeat(userDetails.getUserId(), reservationId) return ResponseEntity.noContent().build() } } @@ -68,17 +69,14 @@ data class BriefReservation( val performanceDate: LocalDate, val reservationDate: LocalDate, ) -data class AvailableSeat( - val reservationId: String, - val seat: Seat, -) data class GetAvailableSeatsResponse( - val availableSeats: List, + val availableSeats: List, ) data class ReserveSeatRequest( - val reservationId: String, + val performanceEventId: String, + val seatId: String, ) data class ReserveSeatResponse( @@ -92,7 +90,3 @@ data class GetMyReservationsResponse( data class GetReservedSeatDetailResponse( val reservedSeat: Reservation ) - -data class CancelReserveSeatRequest( - val reservationId: String, -) diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt index 6c17a15..cf38d26 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationEntity.kt @@ -11,10 +11,16 @@ import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint import java.time.LocalDate @Entity -@Table(name = "reservations") +@Table( + name = "reservations", + uniqueConstraints = [ + UniqueConstraint(name = "UniquePerfEvAndSeat", columnNames = ["performance_event_id", "seat_id"]) + ] +) class ReservationEntity( @Id @GeneratedValue(strategy = GenerationType.UUID) @@ -26,10 +32,8 @@ class ReservationEntity( @JoinColumn(name = "seat_id", nullable = false) val seat: SeatEntity, @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "performance_event_id") + @JoinColumn(name = "performance_event_id", nullable = false) val performanceEvent: PerformanceEventEntity, @Column(name = "reservation_date") var reservationDate: LocalDate?, - @Column(name = "reserved") - var reserved: Boolean = false, ) diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt index 826d118..376412a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/ReservationRepository.kt @@ -9,9 +9,5 @@ interface ReservationRepository : JpaRepository { @Query("SELECT r FROM ReservationEntity r WHERE r.user.id = :userId ORDER BY r.reservationDate DESC") fun findByUserId(userId: String): List - fun findByPerformanceEventIdAndReservedIsFalse(performanceEventId: String): List - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT r FROM ReservationEntity r WHERE r.id = :id") - fun findByIdWithWriteLock(id: String): ReservationEntity? + fun findByPerformanceEventId(performanceEventId: String): List } diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatCreationService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatCreationService.kt index cc8c923..e48691a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatCreationService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatCreationService.kt @@ -46,19 +46,4 @@ class SeatCreationService( } } } - - @Transactional - fun createEmptyReservations(performanceEventId: String) { - val performanceEventEntity = performanceEventRepository.findByIdOrNull(performanceEventId) ?: throw PerformanceEventNotFoundException() - val seats = seatRepository.findByPerformanceHall(performanceEventEntity.performanceHall) - val emptyReservations = - seats.map { - ReservationEntity( - seat = it, - performanceEvent = performanceEventEntity, - reservationDate = null, - ) - } - reservationRepository.saveAll(emptyReservations) - } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt index 8b9778f..2edd0fb 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/service/SeatService.kt @@ -6,13 +6,17 @@ import com.wafflestudio.interpark.seat.ReservationNotFoundException import com.wafflestudio.interpark.seat.ReservationPermissionDeniedException import com.wafflestudio.interpark.seat.ReservedAlreadyException import com.wafflestudio.interpark.seat.ReservedYetException +import com.wafflestudio.interpark.seat.SeatNotFoundException +import com.wafflestudio.interpark.seat.WrongSeatException import com.wafflestudio.interpark.seat.controller.BriefReservation import com.wafflestudio.interpark.seat.controller.Reservation import com.wafflestudio.interpark.seat.controller.Seat +import com.wafflestudio.interpark.seat.persistence.ReservationEntity import com.wafflestudio.interpark.seat.persistence.ReservationRepository import com.wafflestudio.interpark.seat.persistence.SeatRepository import com.wafflestudio.interpark.user.AuthenticateException import com.wafflestudio.interpark.user.persistence.UserRepository +import org.springframework.dao.DataIntegrityViolationException import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -21,33 +25,43 @@ import java.time.LocalDate @Service class SeatService( private val reservationRepository: ReservationRepository, + private val seatRepository: SeatRepository, private val performanceEventRepository: PerformanceEventRepository, private val userRepository: UserRepository, ) { @Transactional - fun getAvailableSeats(performanceEventId: String): List> { - performanceEventRepository.findByIdOrNull(performanceEventId) ?: throw PerformanceEventNotFoundException() - val availableReservations = reservationRepository.findByPerformanceEventIdAndReservedIsFalse(performanceEventId) - val availableSeats = availableReservations.map { Pair(it.id!!, Seat.fromEntity(it.seat)) } + fun getAvailableSeats(performanceEventId: String): List { + val performanceEvent = performanceEventRepository.findByIdOrNull(performanceEventId) ?: throw PerformanceEventNotFoundException() + val reservedSeats = reservationRepository.findByPerformanceEventId(performanceEventId).map { it.seat } + val allSeats = seatRepository.findByPerformanceHall(performanceEvent.performanceHall) + val availableSeats = allSeats.filter { it !in reservedSeats }.map { Seat.fromEntity(it) } return availableSeats } @Transactional fun reserveSeat( userId: String, - reservationId: String, + performanceEventId: String, + seatId: String, ): String { val targetUser = userRepository.findByIdOrNull(userId) ?: throw AuthenticateException() - val targetReservation = reservationRepository.findByIdWithWriteLock(reservationId) ?: throw ReservationNotFoundException() - - if (targetReservation.reserved) throw ReservedAlreadyException() + val targetSeat = seatRepository.findByIdOrNull(seatId) ?: throw SeatNotFoundException() + val targetPerformanceEvent = performanceEventRepository.findByIdOrNull(performanceEventId) ?: throw PerformanceEventNotFoundException() + if(targetSeat.performanceHall != targetPerformanceEvent.performanceHall) { throw WrongSeatException() } + val newReservation = ReservationEntity( + user = targetUser, + seat = targetSeat, + performanceEvent = targetPerformanceEvent, + reservationDate = LocalDate.now(), + ) - targetReservation.user = targetUser - targetReservation.reserved = true - targetReservation.reservationDate = LocalDate.now() - reservationRepository.save(targetReservation) + val savedReservationId = try { + reservationRepository.saveAndFlush(newReservation).id!! + } catch (e: DataIntegrityViolationException) { + throw ReservedAlreadyException() + } - return reservationId + return savedReservationId } @Transactional @@ -107,8 +121,6 @@ class SeatService( throw ReservationPermissionDeniedException() } - reservationEntity.user = null - reservationEntity.reservationDate = null - reservationEntity.reserved = false + reservationRepository.delete(reservationEntity) } } diff --git a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt index d4dfcb8..64ed498 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SeatIntegrationTest.kt @@ -18,13 +18,11 @@ import java.util.UUID @AutoConfigureMockMvc @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Transactional class SeatIntegrationTest @Autowired constructor( private val mvc: MockMvc, private val mapper: ObjectMapper, - private val seatCreationService: SeatCreationService, ) { private lateinit var accessToken: String private lateinit var performanceEventId: String @@ -98,7 +96,7 @@ constructor( @Test fun `좌석을 예매할 수 있고 예매되면 더 이상 예매되지 못한다`() { - val reservationId = mvc.perform( + val seatId = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) .andReturn() @@ -106,27 +104,34 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { val availableSeats = mapper.readTree(it).get("availableSeats") - availableSeats[0].get("reservationId").asText() + availableSeats[0].get("id").asText() } mvc.perform( post("/api/v1/reservation/reserve") .content( mapper.writeValueAsString( mapOf( - "reservationId" to reservationId, + "performanceEventId" to performanceEventId, + "seatId" to seatId, ), ), ) .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) - ).andExpect(status().`is`(200)) + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("reservationId").asText() } + //한번 예매된 좌석은 예매되지 않는다 mvc.perform( post("/api/v1/reservation/reserve") .content( mapper.writeValueAsString( mapOf( - "reservationId" to reservationId, + "performanceEventId" to performanceEventId, + "seatId" to seatId, ), ), ) @@ -137,7 +142,7 @@ constructor( @Test fun `본인의 예매내역을 확인할 수 있다`() { - val reservationId = mvc.perform( + val seatId = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) .andReturn() @@ -145,20 +150,25 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { val availableSeats = mapper.readTree(it).get("availableSeats") - availableSeats[0].get("reservationId").asText() + availableSeats[0].get("id").asText() } - mvc.perform( + val reservationId = mvc.perform( post("/api/v1/reservation/reserve") .content( mapper.writeValueAsString( mapOf( - "reservationId" to reservationId, + "performanceEventId" to performanceEventId, + "seatId" to seatId, ), ), ) .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) - ).andExpect(status().`is`(200)) + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("reservationId").asText() } val myReservations = mvc.perform( get("/api/v1/me/reservation") @@ -176,7 +186,7 @@ constructor( @Test fun `본인의 예매를 자세히 볼 수 있다`() { - val reservationId = mvc.perform( + val seatId = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) .andReturn() @@ -184,22 +194,26 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { val availableSeats = mapper.readTree(it).get("availableSeats") - availableSeats[0].get("reservationId").asText() + availableSeats[0].get("id").asText() } - mvc.perform( + val reservationId = mvc.perform( post("/api/v1/reservation/reserve") .content( mapper.writeValueAsString( mapOf( - "reservationId" to reservationId, + "performanceEventId" to performanceEventId, + "seatId" to seatId, ), ), ) .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) - ).andExpect(status().`is`(200)) - - val myReservationIds = mvc.perform( + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("reservationId").asText() } + mvc.perform( get("/api/v1/reservation/detail/$reservationId") .header("Authorization", "Bearer $accessToken") ).andExpect(status().`is`(200)) @@ -207,7 +221,7 @@ constructor( @Test fun `좌석을 취소할 수 있다`() { - val reservationId = mvc.perform( + val seatId = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) .andReturn() @@ -215,30 +229,28 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { val availableSeats = mapper.readTree(it).get("availableSeats") - availableSeats[0].get("reservationId").asText() + availableSeats[0].get("id").asText() } - mvc.perform( + val reservationId = mvc.perform( post("/api/v1/reservation/reserve") .content( mapper.writeValueAsString( mapOf( - "reservationId" to reservationId, + "performanceEventId" to performanceEventId, + "seatId" to seatId, ), ), ) .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) - ).andExpect(status().`is`(200)) + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("reservationId").asText() } mvc.perform( - post("/api/v1/reservation/cancel") - .content( - mapper.writeValueAsString( - mapOf( - "reservationId" to reservationId, - ), - ), - ) + delete("/api/v1/reservation/$reservationId") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) ).andExpect(status().`is`(204)) @@ -249,18 +261,19 @@ constructor( .content( mapper.writeValueAsString( mapOf( - "reservationId" to reservationId, + "performanceEventId" to performanceEventId, + "seatId" to seatId, ), ), ) .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) - ).andExpect(status().`is`(200)) + ).andExpect(status().`is`(201)) } @Test fun `다른 사람은 좌석을 취소할 수 없다`() { - val reservationId = mvc.perform( + val seatId = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) .andReturn() @@ -268,15 +281,33 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { val availableSeats = mapper.readTree(it).get("availableSeats") - availableSeats[0].get("reservationId").asText() + availableSeats[0].get("id").asText() } + val reservationId = mvc.perform( + post("/api/v1/reservation/reserve") + .content( + mapper.writeValueAsString( + mapOf( + "performanceEventId" to performanceEventId, + "seatId" to seatId, + ), + ), + ) + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("reservationId").asText() } + mvc.perform( post("/api/v1/local/signup") .content( mapper.writeValueAsString( mapOf( - "username" to "correct2", + "username" to "seatTest2", "password" to "12345678", "nickname" to "examplename", "phoneNumber" to "010-0000-0000", @@ -293,7 +324,7 @@ constructor( .content( mapper.writeValueAsString( mapOf( - "username" to "correct2", + "username" to "seatTest2", "password" to "12345678", ), ), @@ -306,27 +337,7 @@ constructor( .let { mapper.readTree(it).get("accessToken").asText() } mvc.perform( - post("/api/v1/reservation/reserve") - .content( - mapper.writeValueAsString( - mapOf( - "reservationId" to reservationId, - ), - ), - ) - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - ).andExpect(status().`is`(200)) - - mvc.perform( - post("/api/v1/reservation/cancel") - .content( - mapper.writeValueAsString( - mapOf( - "reservationId" to reservationId, - ), - ), - ) + delete("/api/v1/reservation/$reservationId") .header("Authorization", "Bearer $otherAccessToken") .contentType(MediaType.APPLICATION_JSON) ).andExpect(status().`is`(403)) diff --git a/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt b/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt index 445c67a..f9ad629 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/SimultaneousTest.kt @@ -85,7 +85,7 @@ constructor( performanceEvents[0].get("id").asText() } - val reservationId = mvc.perform( + val seatId = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) .andReturn() @@ -93,7 +93,7 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { val availableSeats = mapper.readTree(it).get("availableSeats") - availableSeats[0].get("reservationId").asText() + availableSeats[0].get("id").asText() } val results = mutableListOf() @@ -106,7 +106,8 @@ constructor( .content( mapper.writeValueAsString( mapOf( - "reservationId" to reservationId, + "performanceEventId" to performanceEventId, + "seatId" to seatId, ), ), ) @@ -114,7 +115,7 @@ constructor( .contentType(MediaType.APPLICATION_JSON) ).andReturn().response.status results.add(responseStatus) - if (responseStatus == 200) { successCnt.incrementAndGet() } + if (responseStatus == 201) { successCnt.incrementAndGet() } if (responseStatus == 409) { conflictCnt.incrementAndGet() } } } @@ -182,7 +183,7 @@ constructor( performanceEvents[0].get("id").asText() } - val reservationId = mvc.perform( + val seatId = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) .andReturn() @@ -190,7 +191,7 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { val availableSeats = mapper.readTree(it).get("availableSeats") - availableSeats[0].get("reservationId").asText() + availableSeats[0].get("id").asText() } val results = mutableListOf() @@ -203,7 +204,8 @@ constructor( .content( mapper.writeValueAsString( mapOf( - "reservationId" to reservationId, + "performanceEventId" to performanceEventId, + "seatId" to seatId, ), ), ) @@ -211,7 +213,7 @@ constructor( .contentType(MediaType.APPLICATION_JSON) ).andReturn().response.status results.add(responseStatus) - if (responseStatus == 200) { successCnt.incrementAndGet() } + if (responseStatus == 201) { successCnt.incrementAndGet() } if (responseStatus == 409) { conflictCnt.incrementAndGet() } } } @@ -279,7 +281,7 @@ constructor( performanceEvents[0].get("id").asText() } - val reservationId = mvc.perform( + val seatId = mvc.perform( get("/api/v1/seat/$performanceEventId/available") ).andExpect(status().`is`(200)) .andReturn() @@ -287,7 +289,7 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { val availableSeats = mapper.readTree(it).get("availableSeats") - (1..10).map { availableSeats[it].get("reservationId").asText() } + (1..10).map {availableSeats[it].get("id").asText() } } val results = mutableListOf() @@ -300,7 +302,8 @@ constructor( .content( mapper.writeValueAsString( mapOf( - "reservationId" to reservationId[it], + "performanceEventId" to performanceEventId, + "seatId" to seatId[it], ), ), ) @@ -308,7 +311,7 @@ constructor( .contentType(MediaType.APPLICATION_JSON) ).andReturn().response.status results.add(responseStatus) - if (responseStatus == 200) { successCnt.incrementAndGet() } + if (responseStatus == 201) { successCnt.incrementAndGet() } if (responseStatus == 409) { conflictCnt.incrementAndGet() } } } diff --git a/src/test/resources/SeatApi.http b/src/test/resources/SeatApi.http index f102ad7..34efbbc 100644 --- a/src/test/resources/SeatApi.http +++ b/src/test/resources/SeatApi.http @@ -24,13 +24,34 @@ GET http://localhost:80/api/v1/performance/search Accept: application/json ### event 찾기 -GET http://localhost:80/api/v1/performance-event/e1656490-a5be-446e-b119-d8efa393dd05/2025-01-11 -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkODUyNjcwMy0zM2Y2LTRmM2ItYTdhMC1mOGZlOGQ3NTcxNDAiLCJpYXQiOjE3MzcyNzcyMjQsImV4cCI6MTczNzI3ODEyNH0.KObDWrRBDRZU-iNyFHHherkbxIjigmjpVn-Iv5Lx3yY +GET http://localhost:80/api/v1/performance-event/e3c2dd31-d867-4279-8a3b-869829f134f4/2025-05-11 +Accept: application/json ### event 받기 GET http://localhost:80/api/v1/performance-event -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkODUyNjcwMy0zM2Y2LTRmM2ItYTdhMC1mOGZlOGQ3NTcxNDAiLCJpYXQiOjE3MzcyNzcyMjQsImV4cCI6MTczNzI3ODEyNH0.KObDWrRBDRZU-iNyFHHherkbxIjigmjpVn-Iv5Lx3yY ### 가능한 좌석 받기 -GET http://localhost:80/api/v1/seat/eade9900-a141-4532-bd2c-c362ceb7c8c0/available +GET http://localhost:80/api/v1/seat/6b5b9259-d396-49fa-bb9c-27abc6094c36/available Accept: application/json + +### 예매하기 +POST http://localhost/api/v1/reservation/reserve +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI5MzJkYjdkMC1jNWUzLTRlNzUtODY5Ni1iZTlkZWYxMGQzMDciLCJpYXQiOjE3Mzc4MjM2MDUsImV4cCI6MTczNzgyNDUwNX0.obnMoxlszxQwkM6Ltg-9Rsd2QGu0qJckejaMp2eqs-E +Content-Type: application/json + +{ + "performanceEventId": "6b5b9259-d396-49fa-bb9c-27abc6094c36", + "seatId": "fd9a1f0f-56d8-4d2b-8ad4-47e70b39fc0c" +} + +### 예매정보 확인하기 +GET http://localhost/api/v1/me/reservation +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI5MzJkYjdkMC1jNWUzLTRlNzUtODY5Ni1iZTlkZWYxMGQzMDciLCJpYXQiOjE3Mzc4MjM2MDUsImV4cCI6MTczNzgyNDUwNX0.obnMoxlszxQwkM6Ltg-9Rsd2QGu0qJckejaMp2eqs-E + +### 예매 취소하기 +DELETE http://localhost/api/v1/reservation/7e5d1f20-dfad-4978-b1d5-2f06da8479ce +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI5MzJkYjdkMC1jNWUzLTRlNzUtODY5Ni1iZTlkZWYxMGQzMDciLCJpYXQiOjE3Mzc4MjM2MDUsImV4cCI6MTczNzgyNDUwNX0.obnMoxlszxQwkM6Ltg-9Rsd2QGu0qJckejaMp2eqs-E + +### 예매정보 확인하기 +GET http://localhost/api/v1/me/reservation +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI5MzJkYjdkMC1jNWUzLTRlNzUtODY5Ni1iZTlkZWYxMGQzMDciLCJpYXQiOjE3Mzc4MjM2MDUsImV4cCI6MTczNzgyNDUwNX0.obnMoxlszxQwkM6Ltg-9Rsd2QGu0qJckejaMp2eqs-E From 7e54f3ae148154a6bc7670ac616cbc3ddb16a0fb Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Tue, 28 Jan 2025 19:30:24 +0900 Subject: [PATCH 093/162] add: Review, Reply DTO Instant -> LocalDatTime --- .../interpark/review/controller/Reply.kt | 15 +++++++++++---- .../interpark/review/controller/Review.kt | 14 ++++++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt index b533d39..846e267 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt @@ -2,13 +2,16 @@ package com.wafflestudio.interpark.review.controller import com.wafflestudio.interpark.review.persistence.ReplyEntity import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId data class Reply( val id: String, val author: String, val content: String, - val createdAt: Instant, - val updatedAt: Instant, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, ) { companion object { fun fromEntity(entity: ReplyEntity): Reply { @@ -16,9 +19,13 @@ data class Reply( id = entity.id!!, author = entity.author.nickname, content = entity.content, - createdAt = entity.createdAt, - updatedAt = entity.updatedAt, + createdAt = convertInstantToKoreanTime(entity.createdAt), + updatedAt = convertInstantToKoreanTime(entity.updatedAt), ) } + + private fun convertInstantToKoreanTime(instant: Instant): LocalDateTime { + return LocalDateTime.ofInstant(instant, ZoneId.of("Asia/Seoul")) + } } } \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt index 3f91d49..d9e42b2 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt @@ -2,6 +2,8 @@ package com.wafflestudio.interpark.review.controller import com.wafflestudio.interpark.review.persistence.ReviewEntity import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId data class Review( val id: String, @@ -9,8 +11,8 @@ data class Review( val rating: Int, val title: String, val content: String, - val createdAt: Instant, - val updatedAt: Instant, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, val likeCount: Int, val replyCount: Int, ) { @@ -22,11 +24,15 @@ data class Review( rating = entity.rating, title = entity.title, content = entity.content, - createdAt = entity.createdAt, - updatedAt = entity.updatedAt, + createdAt = convertInstantToKoreanTime(entity.createdAt), + updatedAt = convertInstantToKoreanTime(entity.updatedAt), likeCount = entity.reviewLikes.size, replyCount = replyCount, ) } + + private fun convertInstantToKoreanTime(instant: Instant): LocalDateTime { + return LocalDateTime.ofInstant(instant, ZoneId.of("Asia/Seoul")) + } } } From ef5cff5fb6fb92baf46c77358d39f4db86c1775f Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Tue, 28 Jan 2025 21:01:27 +0900 Subject: [PATCH 094/162] feat: Cursor Pagination Tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CursorSpecification은 조건 추가해수는 기능 CursorPageService는 cursor 포함해서 검색하는 기능 CursorEncoder는 cursor를 컨트롤러에서 인코딩/디코딩 할 수 있도록 하는 기능 --- .../interpark/pagination/CursorEncoder.kt | 29 ++++++++++++ .../interpark/pagination/CursorPageService.kt | 46 +++++++++++++++++++ .../pagination/CursorSpecification.kt | 41 +++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 src/main/kotlin/com/wafflestudio/interpark/pagination/CursorEncoder.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/pagination/CursorSpecification.kt diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorEncoder.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorEncoder.kt new file mode 100644 index 0000000..e76889f --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorEncoder.kt @@ -0,0 +1,29 @@ +package com.wafflestudio.interpark.pagination + +import java.util.Base64 +import kotlin.text.Charsets.UTF_8 + +object CursorEncoder { + fun encodeCursor(fieldCursor: Any, idCursor: String): String { + val cursorString = "$fieldCursor,$idCursor" + return Base64.getEncoder().encodeToString(cursorString.toByteArray(UTF_8)) + } + + fun decodeCursor(encodedCursor: String): Pair? { + return try { + val decodedString = String(Base64.getDecoder().decode(encodedCursor), UTF_8) + val parts = decodedString.split(",") + if( parts.size == 2 ) { + val fieldCursor = parts[0] + val idCursor = parts[1] + + fieldCursor to idCursor + } + else { + null + } + } catch (e: Exception) { + null + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt new file mode 100644 index 0000000..fa3e7ad --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt @@ -0,0 +1,46 @@ +package com.wafflestudio.interpark.pagination + +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.data.jpa.domain.Specification +import org.springframework.data.jpa.repository.JpaSpecificationExecutor + +abstract class CursorPageService( + private val repository: JpaSpecificationExecutor +) { + fun findAllWithCursor( + cursorPageable: CursorPageable, + specification: Specification? = null, + ): List { + val cursor = if (cursorPageable.fieldCursor != null && cursorPageable.idCursor != null) { + cursorPageable.fieldCursor to cursorPageable.idCursor + } else { + null + } + + val cursorSpec = CursorSpecification.withCursor( + cursor = cursor, + sortFieldName = cursorPageable.sortFieldName, + isDescending = cursorPageable.isDescending, + ) + + val combinedSpec = if(specification != null) { + Specification.where(cursorSpec).and(specification) + } else { + cursorSpec + } + + val sortDirection = if(cursorPageable.isDescending) Sort.Direction.DESC else Sort.Direction.ASC + val pageable = PageRequest.of(0, cursorPageable.size, Sort.by(sortDirection, cursorPageable.sortFieldName, "id")) + + return repository.findAll(combinedSpec, pageable).content + } +} + +data class CursorPageable( + val fieldCursor: Any?, + val idCursor: String?, + val sortFieldName: String = "createdAt", + val isDescending: Boolean = true, + val size: Int = 5, +) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorSpecification.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorSpecification.kt new file mode 100644 index 0000000..6759b49 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorSpecification.kt @@ -0,0 +1,41 @@ +package com.wafflestudio.interpark.pagination + +import org.springframework.data.jpa.domain.Specification + +// 정렬기준이 되는 field를 기준으로 조건을 설정 +object CursorSpecification { + fun withCursor( + cursor: Pair?, + sortFieldName: String, + isDescending: Boolean = true, + ): Specification? { + if (cursor == null) return null + val fieldCursor = cursor.first + val idCursor = cursor.second + + return Specification {root, _, cb -> + val fieldPath = root.get>(sortFieldName) + val idPath = root.get("id") + + if(isDescending) { + cb.or( + cb.lessThan(fieldPath, fieldCursor as Comparable), + cb.and( + cb.equal(fieldPath, fieldCursor), + cb.lessThan(idPath, idCursor) + ) + ) + } + else { + cb.or( + cb.greaterThan(fieldPath, fieldCursor as Comparable), + cb.and( + cb.equal(fieldPath, fieldCursor), + cb.greaterThan(idPath, idCursor) + ) + ) + } + + } + } +} \ No newline at end of file From a3de380bbaf3d2ff6cc6b5460d4103c5f70e7007 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Tue, 28 Jan 2025 21:31:57 +0900 Subject: [PATCH 095/162] =?UTF-8?q?feat:=20CursorPageable=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interpark/pagination/CursorPageService.kt | 20 +++++++++---------- .../controller/PerformanceController.kt | 11 +++++++++- .../performance/service/PerformanceService.kt | 7 +++++-- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt index fa3e7ad..fb6d365 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt @@ -4,6 +4,7 @@ import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort import org.springframework.data.jpa.domain.Specification import org.springframework.data.jpa.repository.JpaSpecificationExecutor +import java.awt.Cursor abstract class CursorPageService( private val repository: JpaSpecificationExecutor @@ -12,15 +13,11 @@ abstract class CursorPageService( cursorPageable: CursorPageable, specification: Specification? = null, ): List { - val cursor = if (cursorPageable.fieldCursor != null && cursorPageable.idCursor != null) { - cursorPageable.fieldCursor to cursorPageable.idCursor - } else { - null - } + val cursor = cursorPageable.decodeCursor() val cursorSpec = CursorSpecification.withCursor( cursor = cursor, - sortFieldName = cursorPageable.sortFieldName, + sortFieldName = cursorPageable.sortFieldName ?: "id", isDescending = cursorPageable.isDescending, ) @@ -38,9 +35,12 @@ abstract class CursorPageService( } data class CursorPageable( - val fieldCursor: Any?, - val idCursor: String?, - val sortFieldName: String = "createdAt", + val cursor: String?, + val sortFieldName: String? = null, val isDescending: Boolean = true, val size: Int = 5, -) \ No newline at end of file +) { + fun decodeCursor(): Pair? { + return cursor?.let {CursorEncoder.decodeCursor(it) } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index f5867d2..a4c3768 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -1,5 +1,6 @@ package com.wafflestudio.interpark.performance.controller +import com.wafflestudio.interpark.pagination.CursorPageable import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import io.swagger.v3.oas.annotations.Operation import com.wafflestudio.interpark.performance.service.PerformanceService @@ -24,8 +25,16 @@ class PerformanceController( fun searchPerformance( @RequestParam title: String?, @RequestParam category: PerformanceCategory?, + @RequestParam cursor: String?, ): ResponseEntity { - val queriedPerformances = performanceService.searchPerformance(title, category) + // @RequestParam(defaultValue) 대신 내부적으로 기본값 처리 + val sortField: String? = null + val isDescending = false + val size = 5 + + val cursorPageable= CursorPageable(cursor, sortField, isDescending, size) + + val queriedPerformances = performanceService.searchPerformance(title, category, cursorPageable) return ResponseEntity.ok(queriedPerformances) } diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt index 029ad82..acc81e5 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt @@ -1,5 +1,7 @@ package com.wafflestudio.interpark.performance.service +import com.wafflestudio.interpark.pagination.CursorPageService +import com.wafflestudio.interpark.pagination.CursorPageable import com.wafflestudio.interpark.performance.PerformanceNotFoundException import com.wafflestudio.interpark.performance.controller.BriefPerformanceDetail import com.wafflestudio.interpark.performance.controller.Performance @@ -14,10 +16,11 @@ import org.springframework.data.repository.findByIdOrNull class PerformanceService( private val performanceRepository: PerformanceRepository, private val performanceEventRepository: PerformanceEventRepository, -) { +) : CursorPageService(performanceRepository) { fun searchPerformance( title: String?, category: PerformanceCategory?, + cursorPageable: CursorPageable, ): List { // 시작점: 아무 조건이 없는 스펙 var spec: Specification = Specification.where(null) @@ -33,7 +36,7 @@ class PerformanceService( } // 스펙이 결국 아무 조건도 없으면 -> 전체 검색 - val performanceEntities = performanceRepository.findAll(spec) + val performanceEntities = findAllWithCursor(cursorPageable, spec) // BriefDetail DTO 변환 return performanceEntities.map { performanceEntity -> From c3f33a70c1c8ec2b9ded746a74db5bf669ba3421 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Tue, 28 Jan 2025 21:50:12 +0900 Subject: [PATCH 096/162] fix: modify CursorPageable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 디폴트 값을 데이터 클래스가 생성될 때 채워지는 것으로 적용 --- .../interpark/pagination/CursorPageService.kt | 4 ++-- .../performance/controller/PerformanceController.kt | 8 ++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt index fb6d365..0fe4a96 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt @@ -17,7 +17,7 @@ abstract class CursorPageService( val cursorSpec = CursorSpecification.withCursor( cursor = cursor, - sortFieldName = cursorPageable.sortFieldName ?: "id", + sortFieldName = cursorPageable.sortFieldName, isDescending = cursorPageable.isDescending, ) @@ -36,7 +36,7 @@ abstract class CursorPageService( data class CursorPageable( val cursor: String?, - val sortFieldName: String? = null, + val sortFieldName: String = "id", // 기준이 없다면 id만 가지고 정렬 val isDescending: Boolean = true, val size: Int = 5, ) { diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index a4c3768..be6d283 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -27,12 +27,8 @@ class PerformanceController( @RequestParam category: PerformanceCategory?, @RequestParam cursor: String?, ): ResponseEntity { - // @RequestParam(defaultValue) 대신 내부적으로 기본값 처리 - val sortField: String? = null - val isDescending = false - val size = 5 - - val cursorPageable= CursorPageable(cursor, sortField, isDescending, size) + // @RequestParam(defaultValue) 대신 데이터 클래스 내부적으로 기본값 처리 + val cursorPageable= CursorPageable(cursor = cursor) val queriedPerformances = performanceService.searchPerformance(title, category, cursorPageable) return ResponseEntity.ok(queriedPerformances) From 8c946583a5be6edbb72344cd7bfba11cdf0b0c0c Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Tue, 28 Jan 2025 21:59:41 +0900 Subject: [PATCH 097/162] fix: Reply Test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instant 대신 LocalDateTime을 씀에 따라 테스트에서의 파싱도 변경 --- .../com/wafflestudio/interpark/ReplyIntegrationTest.kt | 9 +++++---- .../com/wafflestudio/interpark/ReviewIntegrationTest.kt | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt index a5096eb..c8fe103 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt @@ -12,6 +12,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* import org.springframework.transaction.annotation.Transactional import java.time.Instant +import java.time.LocalDateTime import java.util.UUID @AutoConfigureMockMvc @@ -459,8 +460,8 @@ class ReplyIntegrationTest .response .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it) } - .map { Instant.parse(it.get("createdAt").asText()) } - val isReviewReplySorted = reviewReplies.zipWithNext { a,b -> !a.isBefore(b) }.all {it} + .map { LocalDateTime.parse(it.get("createdAt").asText()) } + val isReviewReplySorted = reviewReplies.zipWithNext { a,b -> a>=b }.all {it} assert (isReviewReplySorted) { "expected review rating sorted but not" } val userReplies = mvc.perform( @@ -471,8 +472,8 @@ class ReplyIntegrationTest .response .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it) } - .map { Instant.parse(it.get("createdAt").asText()) } - val isUserReplySorted = userReplies.zipWithNext { a,b -> !a.isBefore(b) }.all {it} + .map { LocalDateTime.parse(it.get("createdAt").asText()) } + val isUserReplySorted = userReplies.zipWithNext { a,b -> a >=b }.all {it} assert (isUserReplySorted) { "expected user reply sorted but not" } } } diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt index fd3f6c6..6367a7a 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt @@ -13,6 +13,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultHandlers.* import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* import org.springframework.transaction.annotation.Transactional import java.time.Instant +import java.time.LocalDateTime import java.util.UUID @AutoConfigureMockMvc @@ -332,8 +333,8 @@ class ReviewIntegrationTest .response .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it) } - .map { Instant.parse(it.get("createdAt").asText()) } - val isPerformanceReviewSorted = performanceReviews.zipWithNext { a,b -> !a.isBefore(b) }.all {it} + .map { LocalDateTime.parse(it.get("createdAt").asText()) } + val isPerformanceReviewSorted = performanceReviews.zipWithNext { a,b -> a>=b }.all {it} assert (isPerformanceReviewSorted) { "expected sorted performance reviews but not" } val userReviews = mvc.perform( @@ -344,8 +345,8 @@ class ReviewIntegrationTest .response .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it) } - .map { Instant.parse(it.get("createdAt").asText()) } - val isUserReviewSorted = userReviews.zipWithNext { a,b -> !a.isBefore(b) }.all {it} + .map { LocalDateTime.parse(it.get("createdAt").asText()) } + val isUserReviewSorted = userReviews.zipWithNext { a,b -> a>=b }.all {it} assert (isUserReviewSorted) { "expected sorted user reviews but not" } } } From 530a8741f2a759db73572b36bded00ab458bf535 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Wed, 29 Jan 2025 14:37:58 +0900 Subject: [PATCH 098/162] =?UTF-8?q?social=20login=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EC=A4=91=20=EC=A4=91=EA=B0=84=20save?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 7 +- Makefile | 1 + build.gradle.kts | 3 + .../interpark/GlobalExceptionHandler.kt | 13 +- .../interpark/config/JwtConfig.kt | 19 + .../interpark/user/UserAccessTokenUtil.kt | 11 +- .../interpark/user/UserException.kt | 10 + .../user/controller/SocialAuthController.kt | 67 ++++ .../user/controller/UserController.kt | 14 - .../user/controller/UserDetailsImpl.kt | 2 - .../user/persistence/UserIdentityEntity.kt | 4 +- .../user/service/SocialAuthService.kt | 334 ++++++++++++++++++ .../interpark/user/service/UserService.kt | 57 +-- src/main/resources/application.yaml | 31 ++ src/test/resources/application.yaml | 58 ++- 15 files changed, 556 insertions(+), 75 deletions(-) create mode 100644 src/main/kotlin/com/wafflestudio/interpark/config/JwtConfig.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/user/service/SocialAuthService.kt diff --git a/.env b/.env index e45ba25..fb3b838 100644 --- a/.env +++ b/.env @@ -1,3 +1,8 @@ SPRING_DATASOURCE_URL: "jdbc:mysql://mysql-db:3306/testdb" SPRING_DATASOURCE_USERNAME: "user" -SPRING_DATASOURCE_PASSWORD: "somepassword" \ No newline at end of file +SPRING_DATASOURCE_PASSWORD: "somepassword" +JWT_SECRET_KEY: "THISSHOULDBEPROTECTEDASDFASDFASDFASDFASDFASDF" +KAKAO_CLIENT_ID: 293256b48f00360ec0be83d37d46ad4c +KAKAO_CLIENT_SECRET: 72EpfdY23sbmTDmSoTQSsNrXUjISdzvC +NAVER_CLIENT_ID: pgf7q4pBDfBxiTsQMIPf +NAVER_CLIENT_SECRET: UfPTaCnSCK \ No newline at end of file diff --git a/Makefile b/Makefile index cc095ee..6a960d3 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ else endif all: + docker compose up -d mysql $(GRADLE_CMD) build docker build -t myapp:1.0 . docker compose up \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 7afc0ba..9548812 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,7 +22,10 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-oauth2-client") + implementation("org.springframework.boot:spring-boot-starter-webflux") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("io.github.cdimascio:dotenv-kotlin:6.4.1") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.mindrot:jbcrypt:0.4") implementation("com.mysql:mysql-connector-j:8.2.0") diff --git a/src/main/kotlin/com/wafflestudio/interpark/GlobalExceptionHandler.kt b/src/main/kotlin/com/wafflestudio/interpark/GlobalExceptionHandler.kt index 73195c1..7a5c20e 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/GlobalExceptionHandler.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/GlobalExceptionHandler.kt @@ -1,5 +1,6 @@ package com.wafflestudio.interpark +import com.wafflestudio.interpark.user.SocialAccountNotFoundException import org.springframework.http.ResponseEntity import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ControllerAdvice @@ -9,9 +10,19 @@ import org.springframework.web.bind.annotation.ExceptionHandler class GlobalExceptionHandler { @ExceptionHandler(DomainException::class) fun handle(exception: DomainException): ResponseEntity> { + val responseBody = mutableMapOf( + "error" to exception.msg, + "errorCode" to exception.errorCode + ) + + if (exception is SocialAccountNotFoundException) { + responseBody["provider"] = exception.provider.toString() + responseBody["providerId"] = exception.providerId + } + return ResponseEntity .status(exception.httpErrorCode) - .body(mapOf("error" to exception.msg, "errorCode" to exception.errorCode)) + .body(responseBody) } @ExceptionHandler(MethodArgumentNotValidException::class) diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/JwtConfig.kt b/src/main/kotlin/com/wafflestudio/interpark/config/JwtConfig.kt new file mode 100644 index 0000000..fe0f3e9 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/config/JwtConfig.kt @@ -0,0 +1,19 @@ +package com.wafflestudio.interpark.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import io.jsonwebtoken.security.Keys +import java.nio.charset.StandardCharsets +import javax.crypto.SecretKey + +@Configuration +class JwtConfig { + @Value("\${jwt.secret}") + private lateinit var secretKey: String + + @Bean + fun secretKeySpec(): SecretKey { + return Keys.hmacShaKeyFor(secretKey.toByteArray(StandardCharsets.UTF_8)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/UserAccessTokenUtil.kt b/src/main/kotlin/com/wafflestudio/interpark/user/UserAccessTokenUtil.kt index 7804127..adc8282 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/UserAccessTokenUtil.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/UserAccessTokenUtil.kt @@ -4,19 +4,22 @@ import com.wafflestudio.interpark.user.persistence.RefreshTokenEntity import com.wafflestudio.interpark.user.persistence.RefreshTokenRepository import io.jsonwebtoken.Jwts import io.jsonwebtoken.security.Keys +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import java.nio.charset.StandardCharsets import java.util.* +import javax.crypto.SecretKey @Component class UserAccessTokenUtil( private var refreshTokenRepository: RefreshTokenRepository, + private val secretKey: SecretKey ) { fun generateAccessToken(username: String): String { val now = Date() val expiryDate = Date(now.time + ACCESS_EXPIRATION_TIME) return Jwts.builder() - .signWith(SECRET_KEY) + .signWith(secretKey) .setSubject(username) .setIssuedAt(now) .setExpiration(expiryDate) @@ -27,7 +30,7 @@ class UserAccessTokenUtil( return try { val claims = Jwts.parserBuilder() - .setSigningKey(SECRET_KEY) + .setSigningKey(secretKey) .build() .parseClaimsJws(accessToken) .body @@ -80,7 +83,9 @@ class UserAccessTokenUtil( companion object { private const val ACCESS_EXPIRATION_TIME = 1000 * 60 * 15 // 15 minutes private const val REFRESH_EXPIRATION_TIME = 1000 * 60 * 60 * 24 // 1 day - private val SECRET_KEY = Keys.hmacShaKeyFor("THISSHOULDBEPROTECTEDASDFASDFASDFASDFASDFASDF".toByteArray(StandardCharsets.UTF_8)) +// @Value("\${jwt.secret}") +// lateinit var secretKey: String +// private val SECRET_KEY = Keys.hmacShaKeyFor(secretKey.toByteArray(StandardCharsets.UTF_8)) // TODO("비밀키 숨겨야 한다") } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt b/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt index a0b171e..a5153cc 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt @@ -1,6 +1,7 @@ package com.wafflestudio.interpark.user import com.wafflestudio.interpark.DomainException +import com.wafflestudio.interpark.user.persistence.Provider import org.springframework.http.HttpStatus import org.springframework.http.HttpStatusCode @@ -70,3 +71,12 @@ class SocialAccountAlreadyLinkedException : UserException( httpStatusCode = HttpStatus.CONFLICT, msg = "Social Account already linked to another user", ) + +class SocialAccountNotFoundException ( + val provider: Provider, + val providerId: String, +): UserException( + errorCode = 0, + httpStatusCode = HttpStatus.NOT_FOUND, + msg = "This $provider account is not linked to local account", +) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt new file mode 100644 index 0000000..62a5a1f --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt @@ -0,0 +1,67 @@ +package com.wafflestudio.interpark.user.controller + +import com.wafflestudio.interpark.user.persistence.Provider +import com.wafflestudio.interpark.user.service.SocialAuthService +import jakarta.servlet.http.Cookie +import jakarta.servlet.http.HttpServletResponse +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.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/auth") +class SocialAuthController( + val socialAuthService: SocialAuthService +) { + @PostMapping("/{provider}/login") + fun socialLogin( + @PathVariable provider: Provider, + @RequestParam("code") authorizationCode: String, + response: HttpServletResponse + ): ResponseEntity { + val result = socialAuthService.socialLogin(provider, authorizationCode) + val (user, accessToken, refreshToken, providerId) = result + val cookie = + Cookie("refreshToken", refreshToken).apply { + isHttpOnly = true + secure = true + path = "/api/v1/auth" + maxAge = 60 * 60 * 24 * 7 + // TODO("domain 설정하기") + } + response.addCookie(cookie) + + return ResponseEntity.ok(SocialLoginResponse(user, accessToken, provider, providerId)) + } + + @PostMapping("/link") + fun linkSocialAccount( + @RequestBody request: LinkSocialAccountRequest + ): ResponseEntity { + socialAuthService.linkSocialAccount( + username = request.username, + password = request.password, + provider = request.provider, + providerId = request.providerId + ) + return ResponseEntity.ok().build() + } +} + +data class SocialLoginResponse( + val user: User, + val accessToken: String, + val provider: Provider, + val providerId: String +) + +data class LinkSocialAccountRequest( + val username: String, + val password: String, + val provider: Provider, + val providerId: String, +) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt index 009b8c7..a327be9 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt @@ -171,14 +171,6 @@ class UserController( return ResponseEntity.ok(TokenResponse(newAccessToken)) } - - @PostMapping("/api/v1/social/link") - fun linkSocialAccount( - @RequestBody request: LinkSocialAccountRequest - ): ResponseEntity { - userService.linkSocialAccount(request.userId, request.provider, request.providerId) - return ResponseEntity.ok().build() - } } data class SignUpRequest( @@ -206,9 +198,3 @@ data class SignInResponse( data class TokenResponse( val accessToken: String, ) - -data class LinkSocialAccountRequest( - val userId: String, - val provider: Provider, - val providerId: String, -) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt index 66265e8..2208621 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt @@ -1,9 +1,7 @@ package com.wafflestudio.interpark.user.controller -import com.wafflestudio.interpark.user.persistence.UserEntity import com.wafflestudio.interpark.user.persistence.UserIdentityEntity import org.springframework.security.core.GrantedAuthority -import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.userdetails.UserDetails class UserDetailsImpl ( diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt index 4e35567..5421ad4 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt @@ -24,8 +24,8 @@ class UserIdentityEntity( var role: UserRole = UserRole.USER, @Column(name = "hashed_password", nullable = false) val hashedPassword: String, - @OneToMany(mappedBy = "userIdentity", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) - val socialAccounts: MutableList = mutableListOf(), +// @OneToMany(mappedBy = "userIdentity", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) +// val socialAccounts: MutableList = mutableListOf(), ) enum class UserRole : GrantedAuthority { diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/SocialAuthService.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/SocialAuthService.kt new file mode 100644 index 0000000..b022abf --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/SocialAuthService.kt @@ -0,0 +1,334 @@ +package com.wafflestudio.interpark.user.service + +import com.wafflestudio.interpark.user.SignInInvalidPasswordException +import com.wafflestudio.interpark.user.SocialAccountAlreadyLinkedException +import com.wafflestudio.interpark.user.SocialAccountNotFoundException +import com.wafflestudio.interpark.user.UserAccessTokenUtil +import com.wafflestudio.interpark.user.UserIdentityNotFoundException +import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.persistence.Provider +import com.wafflestudio.interpark.user.persistence.SocialAccountEntity +import com.wafflestudio.interpark.user.persistence.SocialAccountRepository +import com.wafflestudio.interpark.user.persistence.UserIdentityEntity +import com.wafflestudio.interpark.user.persistence.UserIdentityRepository +import org.mindrot.jbcrypt.BCrypt +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.reactive.function.client.WebClient + +@Service +class SocialAuthService( + private val socialAccountRepository: SocialAccountRepository, + private val userIdentityRepository: UserIdentityRepository, + private val userAccessTokenUtil: UserAccessTokenUtil, + // 카카오 설정 + @Value("\${spring.security.oauth2.client.provider.kakao.token-uri}") + private val kakaoTokenUri: String, + @Value("\${spring.security.oauth2.client.provider.kakao.user-info-uri}") + private val kakaoUserInfoUri: String, + @Value("\${spring.security.oauth2.client.registration.kakao.client-id}") + private val kakaoClientId: String, + @Value("\${spring.security.oauth2.client.registration.kakao.client-secret:}") + private val kakaoClientSecret: String?, + + // 네이버 설정 + @Value("\${spring.security.oauth2.client.provider.naver.token-uri}") + private val naverTokenUri: String, + @Value("\${spring.security.oauth2.client.provider.naver.user-info-uri}") + private val naverUserInfoUri: String, + @Value("\${spring.security.oauth2.client.registration.naver.client-id}") + private val naverClientId: String, + @Value("\${spring.security.oauth2.client.registration.naver.client-secret}") + private val naverClientSecret: String +) { + @Transactional + fun linkSocialAccount( + username: String, + password: String, + provider: Provider, + providerId: String + ): UserIdentityEntity { + // linkSocialAccount는 회원가입 완료된 로컬 계정에 한해 호출된다고 전제 + // 유저 확인 + val userIdentity = userIdentityRepository.findByUserUsername(username) ?: throw UserIdentityNotFoundException() + // 로컬 계정 패스워드 확인 + if (!BCrypt.checkpw(password, userIdentity.hashedPassword)) { + throw SignInInvalidPasswordException() + } + + // 소셜 계정 중복 확인 + val existingSocialAccount = socialAccountRepository.findByProviderAndProviderId(provider, providerId) + if (existingSocialAccount != null) { + if (existingSocialAccount.userIdentity.user.username != username) { + throw SocialAccountAlreadyLinkedException() + } + } + + // 소셜 계정 생성 및 연동 + val socialAccount = SocialAccountEntity( + userIdentity = userIdentity, + provider = provider, + providerId = providerId + ) + socialAccountRepository.save(socialAccount) + + return userIdentity + } + + fun exchangeCodeForToken( + provider: Provider, + code: String + ): SocialTokenResponse { + return when (provider) { + Provider.KAKAO -> exchangeKakaoToken(code) + Provider.NAVER -> exchangeNaverToken(code) + else -> throw IllegalArgumentException("Unsupported provider: $provider") + } + } + + private fun exchangeKakaoToken( + code: String + ): SocialTokenResponse { + val bodyMap = LinkedMultiValueMap().apply { + this["grant_type"] = "authorization_code" + this["client_id"] = kakaoClientId + if (!kakaoClientSecret.isNullOrEmpty()) { + this["client_secret"] = kakaoClientSecret + } + this["code"] = code + } + // 필요하면 redirect_uri도 추가 + + // (1) webClient 인스턴스 생성 (혹은 주입) + val client = WebClient.builder() + .baseUrl(kakaoTokenUri) + .build() + + // (2) POST 요청 + val response = client.post() + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .bodyValue(bodyMap) + .retrieve() // status 4xx, 5xx 시 오류 발생하게 함 + .bodyToMono(KakaoTokenResponse::class.java) // 비동기 Mono + .block() // 여기서는 동기 블록으로 처리(데모 용) + + // (3) 응답 파싱 + val tokenBody = response ?: throw RuntimeException("Kakao token response is null") + + return SocialTokenResponse( + accessToken = tokenBody.accessToken ?: "", + refreshToken = tokenBody.refreshToken, + tokenType = tokenBody.tokenType, + expiresIn = tokenBody.expiresIn ?: 0 + ) + } + + private fun exchangeNaverToken(code: String): SocialTokenResponse { + val bodyMap = LinkedMultiValueMap().apply { + this["grant_type"] = "authorization_code" + this["client_id"] = naverClientId + this["client_secret"] = naverClientSecret + this["code"] = code + } + + val client = WebClient.builder() + .baseUrl(naverTokenUri) + .build() + + // 1) POST 요청 -> NaverTokenResponse + val tokenResponse = client.post() + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .bodyValue(bodyMap) + .retrieve() + .bodyToMono(NaverTokenResponse::class.java) + .block() // 동기식 블록 (데모용) + + // 2) 응답 처리 및 예외 처리 + requireNotNull(tokenResponse) { "Naver token response is null" } + require(tokenResponse.error == null) { + "Naver token error: ${tokenResponse.error}, desc = ${tokenResponse.errorDescription}" + } + + // 3) 결과를 공통 DTO(SocialTokenResponse)로 변환 + return SocialTokenResponse( + accessToken = tokenResponse.accessToken.orEmpty(), + refreshToken = tokenResponse.refreshToken, + tokenType = tokenResponse.tokenType, + expiresIn = tokenResponse.expiresIn ?: 0 + ) + } + + fun getUserInfo(provider: Provider, accessToken: String): SocialUserInfo { + return when (provider) { + Provider.KAKAO -> getKakaoUserInfo(accessToken) + Provider.NAVER -> getNaverUserInfo(accessToken) + else -> throw IllegalArgumentException("Unsupported provider: $provider") + } + } + + private fun getKakaoUserInfo(accessToken: String): SocialUserInfo { + val client = WebClient.builder() + .baseUrl(kakaoUserInfoUri) // https://kapi.kakao.com/v2/user/me + .build() + + val response = client.get() + .header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + .retrieve() + .bodyToMono(KakaoUserInfoResponse::class.java) + .block() + + val body = response ?: throw RuntimeException("Kakao userinfo is null") + val id = body.id?.toString() ?: throw RuntimeException("Kakao user id missing") + val email = body.kakaoAccount?.email + val nickname = body.kakaoAccount?.profile?.nickname + + return SocialUserInfo( + provider = Provider.KAKAO, + providerId = id, + email = email, + nickname = nickname + ) + } + + private fun getNaverUserInfo(accessToken: String): SocialUserInfo { + val client = WebClient.builder() + .baseUrl(naverUserInfoUri) // 예: "https://openapi.naver.com/v1/nid/me" + .build() + + // 1) GET 요청, Authorization 헤더에 Bearer 토큰 + val response = client.get() + .header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + .retrieve() + .bodyToMono(NaverUserInfoResponse::class.java) + .block() + + // 2) 응답이 null이거나 에러인지 체크 + if (response == null) { + throw RuntimeException("Naver user info response is null") + } + if (response.resultcode != "00") { + throw RuntimeException("Naver userinfo error: ${response.message}") + } + + val detail = response.response + ?: throw RuntimeException("Naver userinfo 'response' field is null") + + // 3) 필요한 식별 정보(id, email, nickname 등) 추출 + val id = detail.id + ?: throw RuntimeException("Naver user id is null") + val email = detail.email + val nickname = detail.nickname + + // 4) 공통 DTO (SocialUserInfo)로 변환 + return SocialUserInfo( + provider = Provider.NAVER, + providerId = id, + email = email, + nickname = nickname + ) + } + + @Transactional + fun socialLogin( + provider: Provider, + code: String, + ) : SocialLoginResult { + // (1) code -> token + val token = exchangeCodeForToken(provider, code) + + // (2) token -> userInfo + val userInfo = getUserInfo(provider, token.accessToken) + + // (3) DB에서 "이미 존재하는" 소셜 계정 찾기 + val socialAccount = socialAccountRepository.findByProviderAndProviderId(provider, userInfo.providerId) + ?: throw SocialAccountNotFoundException(provider, userInfo.providerId) + + val userEntity = socialAccount.userIdentity.user + val user = User.fromEntity(userEntity) + + // (4) 로그인 성공 -> JWT 발행 + return SocialLoginResult( + user = user, + accessToken = userAccessTokenUtil.generateAccessToken(userEntity.id!!), + refreshToken = userAccessTokenUtil.generateRefreshToken(userEntity.id!!), + providerId = userInfo.providerId + ) + } +} + +data class SocialTokenResponse( + val accessToken: String, + val refreshToken: String? = null, + val tokenType: String? = null, + val expiresIn: Int? = null +) + +data class SocialUserInfo( + val provider: Provider, + val providerId: String, + val email: String?, + val nickname: String? + // 소셜 계정의 email과 nickname을 현재는 따로 사용하지 않음 +) + +data class SocialLoginResult( + val user: User, + val accessToken: String, + val refreshToken: String, + val providerId: String + // 필요하면 JWT, refreshToken, etc... +) + +// 카카오 토큰 응답 +data class KakaoTokenResponse( + val tokenType: String? = null, + val accessToken: String? = null, + val expiresIn: Int? = null, + val refreshToken: String? = null, + val refreshTokenExpiresIn: Int? = null, + //@JsonProperty("scope") + //val scope: String? = null +) + +// 카카오 유저정보 응답 +data class KakaoUserInfoResponse( + val id: Long? = null, + val kakaoAccount: KakaoAccount? = null +) + +data class KakaoAccount( + val email: String? = null, + val profile: Profile? = null, +) + +data class Profile( + val nickname: String? = null, + // etc... +) + +// 네이버 토큰 응답 +data class NaverTokenResponse( + val accessToken: String? = null, + val refreshToken: String? = null, + val tokenType: String? = null, + val expiresIn: Int? = null, + val error: String? = null, + val errorDescription: String? = null +) + +// 네이버 유저정보 응답 +data class NaverUserInfoResponse( + val resultcode: String? = null, + val message: String? = null, + val response: NaverUserInfoDetail? = null +) +data class NaverUserInfoDetail( + val id: String?, + val email: String?, + val nickname: String?, + // etc... +) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt index f351fe7..1c807d0 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt @@ -21,7 +21,6 @@ class UserService( private val userIdentityRepository: UserIdentityRepository, private val userAccessTokenUtil: UserAccessTokenUtil, private val socialAccountRepository: SocialAccountRepository, - ) { @Transactional fun signUp( @@ -32,6 +31,7 @@ class UserService( email: String, role: UserRole = UserRole.USER, provider: Provider? = null, + providerId: String? = null, ): User { if (username.length < 6 || username.length > 20) { throw SignUpBadUsernameException() @@ -52,15 +52,25 @@ class UserService( email = email, ), ) - userIdentityRepository.save( - UserIdentityEntity( - user = user, - role = role, - hashedPassword = encryptedPassword, - ), - ) + val userIdentity = + userIdentityRepository.save( + UserIdentityEntity( + user = user, + role = role, + hashedPassword = encryptedPassword, + ), + ) - // TODO: provider가 null이 아니라면 소셜계정 연동해주기 + // 소셜 계정 연동 + if (provider != null && providerId != null) { + socialAccountRepository.save( + SocialAccountEntity( + userIdentity = userIdentity, + provider = provider, + providerId = providerId, + ), + ) + } return User.fromEntity(user) } @@ -98,33 +108,4 @@ class UserService( fun refreshAccessToken(refreshToken: String): Pair { return userAccessTokenUtil.refreshAccessToken(refreshToken) ?: throw AuthenticateException() } - - @Transactional - fun linkSocialAccount( - userId: String, - provider: Provider, - providerId: String - ): UserIdentityEntity { - // 유저 확인 - val userIdentity = userIdentityRepository.findById(userId) - .orElseThrow { UserIdentityNotFoundException() } - - // 소셜 계정 중복 확인 - val existingSocialAccount = socialAccountRepository.findByProviderAndProviderId(provider, providerId) - if (existingSocialAccount != null) { - if (existingSocialAccount.userIdentity.user.id != userId) { - throw SocialAccountAlreadyLinkedException() - } - } - - // 소셜 계정 생성 및 연동 - val socialAccount = SocialAccountEntity( - userIdentity = userIdentity, - provider = provider, - providerId = providerId - ) - socialAccountRepository.save(socialAccount) - - return userIdentity - } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 78136a2..eb38d65 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,4 +1,6 @@ spring: + config: + import: optional:file:.env[.properties] datasource: url: 'jdbc:mysql://localhost:3306/testdb' driver-class-name: com.mysql.cj.jdbc.Driver @@ -14,3 +16,32 @@ spring: format_sql: false show_sql: false ddl_auto: create-drop + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: "{baseUrl}/login/oauth2/code/kakao" # 클라이언트에서 처리한다면 제거 가능 + authorization-grant-type: authorization_code + client-name: Kakao + naver: + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_SECRET} + redirect-uri: "{baseUrl}/login/oauth2/code/naver" + authorization-grant-type: authorization_code + client-name: Naver + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id # 사용자 고유 ID + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response +jwt: + secret: ${JWT_SECRET_KEY} \ No newline at end of file diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 1c6362b..7af7790 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -1,20 +1,50 @@ spring: + config: + import: optional:file:.env[.properties] datasource: - url: 'jdbc:h2:mem:testdb;MODE=MySQL' - driver-class-name: org.h2.Driver - username: sa - password: 1234 + url: 'jdbc:mysql://localhost:3306/testdb' + driver-class-name: com.mysql.cj.jdbc.Driver + username: user + password: somepassword jpa: - database-platform: org.hibernate.dialect.H2Dialect hibernate: - ddl-auto: create + ddl-auto: create-drop + show-sql: true properties: hibernate: - show_sql: true - format_sql: true - dialect: org.hibernate.dialect.H2Dialect - defer-datasource-initialization: true - h2: - console: - enabled: true - path: /h2-console \ No newline at end of file + show_sql: false + profiles: + active: dev + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: "{baseUrl}/login/oauth2/code/kakao" # 클라이언트에서 처리한다면 제거 가능 + authorization-grant-type: authorization_code + client-name: Kakao + naver: + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_SECRET} + redirect-uri: "{baseUrl}/login/oauth2/code/naver" + authorization-grant-type: authorization_code + client-name: Naver + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id # 사용자 고유 ID + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response +jwt: + secret: ${JWT_SECRET_KEY} + +cache: + expire-after-write: 1m + maximum-size: 100 \ No newline at end of file From 933c087ca12684344180e38af8d7dd15cc124bee Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sun, 19 Jan 2025 18:00:01 +0900 Subject: [PATCH 099/162] setup .env --- .env | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 0000000..e45ba25 --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +SPRING_DATASOURCE_URL: "jdbc:mysql://mysql-db:3306/testdb" +SPRING_DATASOURCE_USERNAME: "user" +SPRING_DATASOURCE_PASSWORD: "somepassword" \ No newline at end of file From 91d76d1a8462eba80a31a2e9985bc37145c097f1 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Wed, 29 Jan 2025 19:36:28 +0900 Subject: [PATCH 100/162] add SocialLoginApi.http --- src/test/resources/SocialLoginApi.http | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/test/resources/SocialLoginApi.http diff --git a/src/test/resources/SocialLoginApi.http b/src/test/resources/SocialLoginApi.http new file mode 100644 index 0000000..5a355d9 --- /dev/null +++ b/src/test/resources/SocialLoginApi.http @@ -0,0 +1,9 @@ +### 카카오 로그인 +### 1️⃣ 카카오 인가 코드 요청 (웹 브라우저에서 실행하여 직접 로그인) +# {baseUrl}은 카카오에서 등록한 리다이렉트 URI +GET https://kauth.kakao.com/oauth/authorize + ?client_id={{KAKAO_CLIENT_ID}} + &redirect_uri={{YOUR_REDIRECT_URI}} + &response_type=code + +### From 63178bd1a6cc6667afa6e86d410991d8e27f3e52 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Wed, 29 Jan 2025 19:46:08 +0900 Subject: [PATCH 101/162] remove .env --- .env | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 .env diff --git a/.env b/.env deleted file mode 100644 index fb3b838..0000000 --- a/.env +++ /dev/null @@ -1,8 +0,0 @@ -SPRING_DATASOURCE_URL: "jdbc:mysql://mysql-db:3306/testdb" -SPRING_DATASOURCE_USERNAME: "user" -SPRING_DATASOURCE_PASSWORD: "somepassword" -JWT_SECRET_KEY: "THISSHOULDBEPROTECTEDASDFASDFASDFASDFASDFASDF" -KAKAO_CLIENT_ID: 293256b48f00360ec0be83d37d46ad4c -KAKAO_CLIENT_SECRET: 72EpfdY23sbmTDmSoTQSsNrXUjISdzvC -NAVER_CLIENT_ID: pgf7q4pBDfBxiTsQMIPf -NAVER_CLIENT_SECRET: UfPTaCnSCK \ No newline at end of file From ac313e22d5b63000cf154197a199aecae395e92b Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Wed, 29 Jan 2025 20:21:06 +0900 Subject: [PATCH 102/162] feat: response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasNext와 nextCursor 반환 --- .../interpark/pagination/CursorEncoder.kt | 7 +++-- .../interpark/pagination/CursorPageService.kt | 26 ++++++++++++++++--- .../controller/PerformanceController.kt | 3 ++- .../performance/service/PerformanceService.kt | 16 +++++++++--- .../interpark/PerformanceIntegrationTest.kt | 10 +++---- .../interpark/ReplyIntegrationTest.kt | 2 +- .../interpark/ReviewIntegrationTest.kt | 2 +- .../interpark/ReviewLikeIntegrationTest.kt | 2 +- 8 files changed, 50 insertions(+), 18 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorEncoder.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorEncoder.kt index e76889f..2a7b636 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorEncoder.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorEncoder.kt @@ -4,12 +4,15 @@ import java.util.Base64 import kotlin.text.Charsets.UTF_8 object CursorEncoder { - fun encodeCursor(fieldCursor: Any, idCursor: String): String { + fun encodeCursor(targetEntity: Any, fieldName: String): String { + val idCursor = targetEntity.javaClass.getDeclaredField("id") + val fieldCursor = targetEntity.javaClass.getDeclaredField(fieldName) + val cursorString = "$fieldCursor,$idCursor" return Base64.getEncoder().encodeToString(cursorString.toByteArray(UTF_8)) } - fun decodeCursor(encodedCursor: String): Pair? { + fun decodeCursor(encodedCursor: String): Pair? { return try { val decodedString = String(Base64.getDecoder().decode(encodedCursor), UTF_8) val parts = decodedString.split(",") diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt index 0fe4a96..0ed894e 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt @@ -12,7 +12,7 @@ abstract class CursorPageService( fun findAllWithCursor( cursorPageable: CursorPageable, specification: Specification? = null, - ): List { + ): CursorPageResponse { val cursor = cursorPageable.decodeCursor() val cursorSpec = CursorSpecification.withCursor( @@ -28,9 +28,21 @@ abstract class CursorPageService( } val sortDirection = if(cursorPageable.isDescending) Sort.Direction.DESC else Sort.Direction.ASC - val pageable = PageRequest.of(0, cursorPageable.size, Sort.by(sortDirection, cursorPageable.sortFieldName, "id")) + val pageable = PageRequest.of(0, cursorPageable.size+1, Sort.by(sortDirection, cursorPageable.sortFieldName, "id")) - return repository.findAll(combinedSpec, pageable).content + val results = repository.findAll(combinedSpec, pageable).content + val hasNext = results.size > cursorPageable.size + + val returnData = if(hasNext) results.dropLast(1) else results + val nextCursor = returnData.lastOrNull()?.let { + CursorEncoder.encodeCursor(it, cursorPageable.sortFieldName) + } + + return CursorPageResponse( + data = returnData, + nextCursor = nextCursor, + hasNext = hasNext, + ) } } @@ -43,4 +55,10 @@ data class CursorPageable( fun decodeCursor(): Pair? { return cursor?.let {CursorEncoder.decodeCursor(it) } } -} \ No newline at end of file +} + +data class CursorPageResponse( + val data: List, + val nextCursor: String?, + val hasNext : Boolean, +) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index be6d283..65ce0f2 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -1,5 +1,6 @@ package com.wafflestudio.interpark.performance.controller +import com.wafflestudio.interpark.pagination.CursorPageResponse import com.wafflestudio.interpark.pagination.CursorPageable import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import io.swagger.v3.oas.annotations.Operation @@ -76,7 +77,7 @@ class PerformanceController( } -typealias SearchPerformanceResponse = List +typealias SearchPerformanceResponse = CursorPageResponse data class BriefPerformanceDetail( val id: String, diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt index acc81e5..676e6d9 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt @@ -1,5 +1,6 @@ package com.wafflestudio.interpark.performance.service +import com.wafflestudio.interpark.pagination.CursorPageResponse import com.wafflestudio.interpark.pagination.CursorPageService import com.wafflestudio.interpark.pagination.CursorPageable import com.wafflestudio.interpark.performance.PerformanceNotFoundException @@ -11,6 +12,7 @@ import com.wafflestudio.interpark.performance.persistence.* import org.springframework.data.jpa.domain.Specification import org.springframework.stereotype.Service import org.springframework.data.repository.findByIdOrNull +import java.awt.Cursor @Service class PerformanceService( @@ -21,7 +23,7 @@ class PerformanceService( title: String?, category: PerformanceCategory?, cursorPageable: CursorPageable, - ): List { + ): CursorPageResponse { // 시작점: 아무 조건이 없는 스펙 var spec: Specification = Specification.where(null) @@ -36,10 +38,12 @@ class PerformanceService( } // 스펙이 결국 아무 조건도 없으면 -> 전체 검색 - val performanceEntities = findAllWithCursor(cursorPageable, spec) + val searchResult = findAllWithCursor(cursorPageable, spec) + + val performanceEntities = searchResult.data // BriefDetail DTO 변환 - return performanceEntities.map { performanceEntity -> + val performanceData = performanceEntities.map { performanceEntity -> val performanceEventEntities = performanceEventRepository.findAllByPerformanceId(performanceEntity.id!!) val performanceEvents = if (performanceEventEntities.isEmpty()) { null @@ -56,6 +60,12 @@ class PerformanceService( performanceEvents = performanceEvents ) } + + return CursorPageResponse( + data = performanceData, + nextCursor = searchResult.nextCursor, + hasNext = searchResult.hasNext, + ) } fun getAllPerformance(): List { diff --git a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt index 76e6b3e..9a233a2 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt @@ -122,7 +122,7 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { val node = mapper.readTree(it) - val firstItem = node.firstOrNull() ?: error("Response array is empty") + val firstItem = node.get("data").firstOrNull() ?: error("Response array is empty") val idNode = firstItem.get("id") requireNotNull(idNode) { "ID not found in response item: $firstItem" } idNode.asText() @@ -138,8 +138,8 @@ constructor( .param("title", "지킬앤하이드") .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().`is`(200)) - .andExpect(jsonPath("$[0].title").value("뮤지컬 지킬앤하이드")) - .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$.data[0].title").value("뮤지컬 지킬앤하이드")) + .andExpect(jsonPath("$.data.length()").value(1)) // 5️⃣ 공연 검색 (category 조건) mvc.perform( @@ -148,8 +148,8 @@ constructor( .param("category", PerformanceCategory.CONCERT.name) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().`is`(200)) - .andExpect(jsonPath("$").isArray) // 응답이 배열인지 확인 - .andExpect(jsonPath("$.length()").value(3)) // 배열의 길이가 0인지 확인 + .andExpect(jsonPath("$.data").isArray) // 응답이 배열인지 확인 + .andExpect(jsonPath("$.data.length()").value(3)) // 배열의 길이가 0인지 확인 } @Test diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt index c8fe103..6d9fb55 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt @@ -79,7 +79,7 @@ class ReplyIntegrationTest .response .getContentAsString(Charsets.UTF_8) .let { - val performances = mapper.readTree(it) + val performances = mapper.readTree(it).get("data") performances[0].get("id").asText() } diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt index 6367a7a..fdde46e 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt @@ -79,7 +79,7 @@ class ReviewIntegrationTest .response .getContentAsString(Charsets.UTF_8) .let { - val performances = mapper.readTree(it) + val performances = mapper.readTree(it).get("data") performances[0].get("id").asText() } } diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt index ff570be..359e2c7 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt @@ -112,7 +112,7 @@ constructor( .response .getContentAsString(Charsets.UTF_8) .let { - val performances = mapper.readTree(it) + val performances = mapper.readTree(it).get("data") performances[0].get("id").asText() } From c25de03222b83b47c8ec50bdd790518d3e3af538 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Wed, 29 Jan 2025 20:35:06 +0900 Subject: [PATCH 103/162] write swagger api for socialauthcontroller --- .../user/controller/SocialAuthController.kt | 112 +++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt index 62a5a1f..ac0d842 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt @@ -2,6 +2,10 @@ package com.wafflestudio.interpark.user.controller import com.wafflestudio.interpark.user.persistence.Provider import com.wafflestudio.interpark.user.service.SocialAuthService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse import jakarta.servlet.http.Cookie import jakarta.servlet.http.HttpServletResponse import org.springframework.http.ResponseEntity @@ -13,10 +17,53 @@ import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @RestController -@RequestMapping("/api/v1/auth") +@RequestMapping("/api/v1/social") class SocialAuthController( val socialAuthService: SocialAuthService ) { + @Operation( + summary = "소셜 로그인 요청", + description = """ + 클라이언트가 인가코드와 Provider(ex. KAKAO, NAVER)를 요청본문에 담아 소셜로그인을 요청합니다. + 서버에서는 인가 코드를 통해 소셜 인증 서버에 액세스 토큰을 요청하고 이를 다시 소셜 계정 정보와 교환합니다. + + - 로그인한 소셜 계정이 로컬 계정과 연동되어 있는 계정인 경우 + 기존 로컬 로그인 응답 객체에 provider와 providerId를 추가로 담아 반환합니다. + 이 때, providerId는 사용자 고유 식별자입니다. + + - 로그인한 소셜 계정이 로컬 계정과 연동되어 있지 않은 경우 + 404에러와 본문에 provider, providerId를 담아 반환합니다. + 이 값을 이용해서 추후에 \"/api/v1/social/link\"로 연동 요청을 보내시면 됩니다. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "소셜 로그인 성공", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = SocialLoginResponse::class) + )] + ), + ApiResponse( + responseCode = "404", + description = "소셜 계정이 로컬 계정과 연동되어 있지 않음", + content = [Content( + mediaType = "application/json", + schema = Schema( + type = "object", + example = """ + { + "error": "This KAKAO account is not linked to local account", + "errorCode": 0, + "provider": "KAKAO", + "providerId": "1234567890" + } + """ + ) + )] + ), + ], + ) @PostMapping("/{provider}/login") fun socialLogin( @PathVariable provider: Provider, @@ -38,6 +85,69 @@ class SocialAuthController( return ResponseEntity.ok(SocialLoginResponse(user, accessToken, provider, providerId)) } + @Operation( + summary = "소셜 계정 - 로컬 계정 연동 요청", + description = """ + username, password, provider, providerId를 요청본문에 담아 로컬계정 연동을 요청합니다. + 소셜 계정 유저가 로컬 계정 유저인지 확인하기 위해 username과 password를 통해 인증 절차를 통과해야만 + 연동이 완료됩니다. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "로컬 계정과의 연동 성공", + content = [] + ), + ApiResponse( + responseCode = "404", + description = "username에 해당하는 로컬 계정이 존재하지 않음", + content = [Content( + mediaType = "application/json", + schema = Schema( + type = "object", + example = """ + { + "error": "UserIdentity not found", + "errorCode": 0 + } + """ + ) + )] + ), + ApiResponse( + responseCode = "401", + description = "비밀번호가 유효하지 앟음", + content = [Content( + mediaType = "application/json", + schema = Schema( + type = "object", + example = """ + { + "error": "Invalid Password", + "errorCode": 0 + } + """ + ) + )] + ), + ApiResponse( + responseCode = "409", + description = "이미 연동된 계정에 대해 다시 연동 요청을 보내고 있음", + content = [Content( + mediaType = "application/json", + schema = Schema( + type = "object", + example = """ + { + "error": "Social Account already linked to another user", + "errorCode": 0 + } + """ + ) + )] + ), + ], + ) @PostMapping("/link") fun linkSocialAccount( @RequestBody request: LinkSocialAccountRequest From bd56d3d0881ec614c6024290204cb84d52f8840e Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Wed, 29 Jan 2025 20:38:47 +0900 Subject: [PATCH 104/162] modify social login api docs description --- .../interpark/user/controller/SocialAuthController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt index ac0d842..8f276e0 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt @@ -24,7 +24,7 @@ class SocialAuthController( @Operation( summary = "소셜 로그인 요청", description = """ - 클라이언트가 인가코드와 Provider(ex. KAKAO, NAVER)를 요청본문에 담아 소셜로그인을 요청합니다. + 클라이언트가 인가코드와 provider(ex. KAKAO, NAVER)를 요청에 담아 소셜로그인을 요청합니다. 서버에서는 인가 코드를 통해 소셜 인증 서버에 액세스 토큰을 요청하고 이를 다시 소셜 계정 정보와 교환합니다. - 로그인한 소셜 계정이 로컬 계정과 연동되어 있는 계정인 경우 From 1f971d98b4bdf1e29110d5174f755fce991b1e32 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Wed, 29 Jan 2025 20:47:46 +0900 Subject: [PATCH 105/162] add social login query paramter explanations --- .../interpark/user/controller/SocialAuthController.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt index 8f276e0..0b091a8 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt @@ -3,6 +3,7 @@ package com.wafflestudio.interpark.user.controller import com.wafflestudio.interpark.user.persistence.Provider import com.wafflestudio.interpark.user.service.SocialAuthService import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse @@ -66,8 +67,12 @@ class SocialAuthController( ) @PostMapping("/{provider}/login") fun socialLogin( + @Parameter(description = "소셜 로그인 제공자 (예: KAKAO, NAVER)", example = "KAKAO") @PathVariable provider: Provider, + + @Parameter(description = "인가 코드", example = "4/P7q7W91a-oMsCeLvIaQm6bTrgtp7") @RequestParam("code") authorizationCode: String, + response: HttpServletResponse ): ResponseEntity { val result = socialAuthService.socialLogin(provider, authorizationCode) From a4a9bfbc184fbaf7166c915ce3b804bd5eb8e2b7 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Wed, 29 Jan 2025 23:16:39 +0900 Subject: [PATCH 106/162] =?UTF-8?q?fix:=20nextCursor=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원래 객체 자체를 인코딩해서 ".id",".id"같은 값이 인코딩되어 나왔습니다 get을 사용해서 객체의 값을 가져올 수 있도록 수정했습니다 --- .../interpark/pagination/CursorEncoder.kt | 4 ++-- src/test/resources/GetApiTest.http | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 src/test/resources/GetApiTest.http diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorEncoder.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorEncoder.kt index 2a7b636..2401bd3 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorEncoder.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorEncoder.kt @@ -5,8 +5,8 @@ import kotlin.text.Charsets.UTF_8 object CursorEncoder { fun encodeCursor(targetEntity: Any, fieldName: String): String { - val idCursor = targetEntity.javaClass.getDeclaredField("id") - val fieldCursor = targetEntity.javaClass.getDeclaredField(fieldName) + val idCursor = targetEntity.javaClass.getDeclaredField("id").apply { isAccessible = true }.get(targetEntity) + val fieldCursor = targetEntity.javaClass.getDeclaredField(fieldName).apply { isAccessible = true }.get(targetEntity) val cursorString = "$fieldCursor,$idCursor" return Base64.getEncoder().encodeToString(cursorString.toByteArray(UTF_8)) diff --git a/src/test/resources/GetApiTest.http b/src/test/resources/GetApiTest.http new file mode 100644 index 0000000..72de84a --- /dev/null +++ b/src/test/resources/GetApiTest.http @@ -0,0 +1,16 @@ +### performance 받기 +GET http://localhost/api/v1/performance/search +Accept: application/json + +### 다음 cursor로 요청 +GET http://localhost/api/v1/performance/search?cursor=NzBkOTlhYTktNGI3My00YWMzLTg2NDQtMzE0ZjM3M2EwZThlLDcwZDk5YWE5LTRiNzMtNGFjMy04NjQ0LTMxNGYzNzNhMGU4ZQ== +Accept: application/json + +### 다음 cursor로 요청 +GET http://localhost/api/v1/performance/search?cursor=MGY5NGY1MTAtZDMxYS00MjdmLTg4M2YtM2UyMGRmMWIyNDkyLDBmOTRmNTEwLWQzMWEtNDI3Zi04ODNmLTNlMjBkZjFiMjQ5Mg== +Accept: application/json + +### 다음 cursor로 요청 +GET http://localhost/api/v1/performance/search?cursor=MDkxZTY3YWItNDRiOS00NjlmLTk1NGYtMWFmZjhmNjcwYjI2LDA5MWU2N2FiLTQ0YjktNDY5Zi05NTRmLTFhZmY4ZjY3MGIyNg== +Accept: application/json + From 34d6d09cbb06cb52b9345cb7a0a156242902acd3 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Thu, 30 Jan 2025 02:09:57 +0900 Subject: [PATCH 107/162] =?UTF-8?q?feat:=20Performance=20Pagination=20?= =?UTF-8?q?=EC=99=84=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wafflestudio/interpark/PaginationTest.kt | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt diff --git a/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt new file mode 100644 index 0000000..d198a86 --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt @@ -0,0 +1,190 @@ +package com.wafflestudio.interpark + +import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.interpark.performance.persistence.PerformanceCategory +import com.wafflestudio.interpark.user.persistence.UserRole +import org.hamcrest.Matchers +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Transactional +class PaginationTest +@Autowired +constructor( + private val mvc: MockMvc, + private val mapper: ObjectMapper, +) { + private lateinit var userAccessToken: String + private lateinit var adminAccessToken: String + private lateinit var performanceId: String + + @BeforeEach + fun setUp() { + val username = UUID.randomUUID().toString().take(8) + val adminname = UUID.randomUUID().toString().takeLast(8) + val password = "password123" + + // 1️⃣ 회원가입 + // 일반 유저 + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + "nickname" to "test_user", + "phoneNumber" to "010-0000-0000", + "email" to "test@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + // 관리자 + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to adminname, + "password" to password, + "nickname" to "test_admin", + "phoneNumber" to "010-0000-0000", + "email" to "test@example.com", + "role" to UserRole.ADMIN, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + // 2️⃣ 로그인 → 토큰 획득 + // 일반 유저 + userAccessToken = + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + // 관리자 + adminAccessToken = + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to adminname, + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + // 3️⃣ 테스트용 공연 ID 반환 + performanceId = + mvc.perform( + get("/api/v1/performance/search") + .header("Authorization", "Bearer $userAccessToken") + .param("title", "지킬앤하이드") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val node = mapper.readTree(it) + val firstItem = node.get("data").firstOrNull() ?: error("Response array is empty") + val idNode = firstItem.get("id") + requireNotNull(idNode) { "ID not found in response item: $firstItem" } + idNode.asText() + } + } + + @Test + fun `공연 전체 조회 페이지네이션 테스트`() { + var cursor: String? = null + val maxIteration = 4 + var iterations = 0 + + while(iterations < maxIteration) { + val response = mvc.perform( + get("/api/v1/performance/search") + .apply { cursor?.let { param("cursor", it) } } + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$.data.size()").value(Matchers.greaterThan(0))) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + + val hasNext = response.get("hasNext").asBoolean() + if(!hasNext) { + break + } + cursor = response.get("nextCursor").asText() + + iterations++ + } + } + + @Test + fun `공연 일부 조회 페이지네이션 테스트`() { + var cursor: String? = null + val maxIteration = 4 + var iterations = 0 + + while(iterations < maxIteration) { + val response = mvc.perform( + get("/api/v1/performance/search") + .param("category", PerformanceCategory.CONCERT.name) + .apply { cursor?.let { param("cursor", it) } } + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + // CONCERT는 테스트 코드상에서 3개만 조회되어야 한다 + .andExpect(jsonPath("$.data.size()").value(3)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + + val hasNext = response.get("hasNext").asBoolean() + if(!hasNext) { + break + } + cursor = response.get("nextCursor").asText() + + iterations++ + } + } +} From 550baf1144d413e2e07d2159f1d427792335616e Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Thu, 30 Jan 2025 15:00:37 +0900 Subject: [PATCH 108/162] social endpoint permitall --- .../interpark/security/SecurityConfig.kt | 1 + .../user/controller/SocialAuthController.kt | 10 +++++++++- src/test/resources/SocialLoginApi.http | 14 ++++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt index dd4aa4f..561d70d 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt @@ -38,6 +38,7 @@ class SecurityConfig ( authorize(HttpMethod.GET, "/api/v1/seat/{performanceEventId}/available", permitAll) authorize(HttpMethod.GET, "/api/v1/performance/{performanceId}/review", permitAll) authorize(HttpMethod.GET, "/api/v1/review/{reviewId}/reply", permitAll) + authorize(HttpMethod.POST, "/api/v1/social/**", permitAll) authorize("/api/v1/**", hasAnyRole("USER", "ADMIN")) // 그 외 모두 유저 권한 필요 authorize("/admin/v1/**", hasRole("ADMIN")) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt index 0b091a8..881d472 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt @@ -10,6 +10,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse import jakarta.servlet.http.Cookie import jakarta.servlet.http.HttpServletResponse import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -34,7 +35,7 @@ class SocialAuthController( - 로그인한 소셜 계정이 로컬 계정과 연동되어 있지 않은 경우 404에러와 본문에 provider, providerId를 담아 반환합니다. - 이 값을 이용해서 추후에 \"/api/v1/social/link\"로 연동 요청을 보내시면 됩니다. + 이 값을 이용해서 추후에 "/api/v1/social/link"로 연동 요청을 보내시면 됩니다. """, responses = [ ApiResponse( @@ -165,6 +166,13 @@ class SocialAuthController( ) return ResponseEntity.ok().build() } + + @GetMapping("/callback") + fun callback( + @RequestParam code: String, + ) : ResponseEntity> { + return ResponseEntity.ok(mapOf("code" to code)) + } } data class SocialLoginResponse( diff --git a/src/test/resources/SocialLoginApi.http b/src/test/resources/SocialLoginApi.http index 5a355d9..c0211ef 100644 --- a/src/test/resources/SocialLoginApi.http +++ b/src/test/resources/SocialLoginApi.http @@ -3,7 +3,17 @@ # {baseUrl}은 카카오에서 등록한 리다이렉트 URI GET https://kauth.kakao.com/oauth/authorize ?client_id={{KAKAO_CLIENT_ID}} - &redirect_uri={{YOUR_REDIRECT_URI}} &response_type=code -### +### 2️⃣ (수동 입력) 받은 인가 코드 저장 +@KAKAO_AUTH_CODE = XXXXXXXXXXXXXXXXXX + +### 3️⃣ 카카오 액세스 토큰 요청 +POST https://kauth.kakao.com/oauth/token +Content-Type: application/x-www-form-urlencoded + +grant_type=authorization_code + &client_id={{KAKAO_CLIENT_ID}} + &client_secret={{KAKAO_CLIENT_SECRET}} + &redirect_uri={{YOUR_REDIRECT_URI}} + &code={{KAKAO_AUTH_CODE}} From 55a99eb2da5f0344e7cd0cc05c3453b106da0433 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Thu, 30 Jan 2025 15:19:08 +0900 Subject: [PATCH 109/162] =?UTF-8?q?feat:=20pagination=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=EB=90=9C=20review=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2로 만들었습니다 --- .../review/controller/ReviewController.kt | 13 +++ .../review/persistence/ReviewRepository.kt | 4 +- .../interpark/review/service/ReviewService.kt | 27 +++++- .../interpark/security/SecurityConfig.kt | 1 + .../wafflestudio/interpark/PaginationTest.kt | 89 +++++++++++-------- src/test/resources/GetApiTest.http | 35 ++++++++ 6 files changed, 129 insertions(+), 40 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt index bfb1d00..1bb3170 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt @@ -1,5 +1,7 @@ package com.wafflestudio.interpark.review.controller +import com.wafflestudio.interpark.pagination.CursorPageResponse +import com.wafflestudio.interpark.pagination.CursorPageable import com.wafflestudio.interpark.review.* import com.wafflestudio.interpark.review.service.ReplyService import com.wafflestudio.interpark.review.service.ReviewService @@ -29,6 +31,16 @@ class ReviewController( return ResponseEntity.ok(reviews) } + @GetMapping("/api/v2/performance/{performanceId}/review") + fun getReviews( + @PathVariable performanceId: String, + @RequestParam cursor: String?, + ): ResponseEntity{ + val cursorPageable= CursorPageable(sortFieldName = "createdAt", cursor = cursor) + val reviews = reviewService.getReviewsWithCursor(performanceId, cursorPageable) + return ResponseEntity.ok(reviews) + } + @PostMapping("/api/v1/performance/{performanceId}/review") fun createReview( @RequestBody request: CreateReviewRequest, @@ -80,6 +92,7 @@ class ReviewController( typealias GetReviewResponse = List +typealias GetCursorReviewResponse = CursorPageResponse data class CreateReviewRequest( val rating: Int, diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt index 52720c0..ddd66be 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt @@ -2,10 +2,12 @@ package com.wafflestudio.interpark.review.persistence import jakarta.persistence.LockModeType import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.JpaSpecificationExecutor import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Query -interface ReviewRepository : JpaRepository { +interface ReviewRepository : JpaRepository, + JpaSpecificationExecutor { @Query("SELECT r FROM ReviewEntity r WHERE r.performance.id = :performanceId ORDER BY r.createdAt DESC") fun findByPerformanceId(performanceId: String): List @Query("SELECT r FROM ReviewEntity r WHERE r.author.id = :authorId ORDER BY r.createdAt DESC") diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt index 2307fad..a6c5f80 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt @@ -1,6 +1,10 @@ package com.wafflestudio.interpark.review.service +import com.wafflestudio.interpark.pagination.CursorPageResponse +import com.wafflestudio.interpark.pagination.CursorPageService +import com.wafflestudio.interpark.pagination.CursorPageable import com.wafflestudio.interpark.performance.PerformanceNotFoundException +import com.wafflestudio.interpark.performance.persistence.PerformanceEntity import com.wafflestudio.interpark.performance.persistence.PerformanceRepository import com.wafflestudio.interpark.review.* import com.wafflestudio.interpark.review.controller.Review @@ -12,6 +16,7 @@ import com.wafflestudio.interpark.user.AuthenticateException import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.persistence.UserRepository import jakarta.persistence.EntityManager +import org.springframework.data.jpa.domain.Specification import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -24,7 +29,7 @@ class ReviewService( private val userRepository: UserRepository, private val reviewLikeRepository: ReviewLikeRepository, private val replyService: ReplyService, -) { +) : CursorPageService(reviewRepository) { fun getReviewsByUser(userId: String): List { val reviews: List = reviewRepository @@ -41,6 +46,26 @@ class ReviewService( return reviews } + fun getReviewsWithCursor( + performanceId: String, + cursorPageable: CursorPageable, + ): CursorPageResponse { + val spec: Specification = Specification.where { root, _, cb -> + cb.equal(root.get("performance").get("id"), performanceId) + } + + val searchResult = findAllWithCursor(cursorPageable, spec) + val reviewEntities = searchResult.data + + val reviewData = reviewEntities.map { Review.fromEntity(it, replyService.countReplies(it.id)) } + + return CursorPageResponse( + data = reviewData, + nextCursor = searchResult.nextCursor, + hasNext = searchResult.hasNext, + ) + } + @Transactional fun createReview( authorId: String, diff --git a/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt index dd4aa4f..f261ee1 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt @@ -37,6 +37,7 @@ class SecurityConfig ( authorize(HttpMethod.POST, "/api/v1/auth/refresh_token", permitAll) authorize(HttpMethod.GET, "/api/v1/seat/{performanceEventId}/available", permitAll) authorize(HttpMethod.GET, "/api/v1/performance/{performanceId}/review", permitAll) + authorize(HttpMethod.GET, "/api/v2/performance/{performanceId}/review", permitAll) authorize(HttpMethod.GET, "/api/v1/review/{reviewId}/reply", permitAll) authorize("/api/v1/**", hasAnyRole("USER", "ADMIN")) // 그 외 모두 유저 권한 필요 authorize("/admin/v1/**", hasRole("ADMIN")) diff --git a/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt index d198a86..87b3e9a 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt @@ -26,7 +26,6 @@ constructor( private val mapper: ObjectMapper, ) { private lateinit var userAccessToken: String - private lateinit var adminAccessToken: String private lateinit var performanceId: String @BeforeEach @@ -53,24 +52,6 @@ constructor( .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().`is`(200)) - // 관리자 - mvc.perform( - post("/api/v1/local/signup") - .content( - mapper.writeValueAsString( - mapOf( - "username" to adminname, - "password" to password, - "nickname" to "test_admin", - "phoneNumber" to "010-0000-0000", - "email" to "test@example.com", - "role" to UserRole.ADMIN, - ), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(200)) - // 2️⃣ 로그인 → 토큰 획득 // 일반 유저 userAccessToken = @@ -91,25 +72,6 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it).get("accessToken").asText() } - // 관리자 - adminAccessToken = - mvc.perform( - post("/api/v1/local/signin") - .content( - mapper.writeValueAsString( - mapOf( - "username" to adminname, - "password" to password, - ), - ), - ) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().`is`(200)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it).get("accessToken").asText() } - // 3️⃣ 테스트용 공연 ID 반환 performanceId = mvc.perform( @@ -187,4 +149,55 @@ constructor( iterations++ } } + + @Test + fun `공연의 리뷰 조회 페이지네이션 테스트`() { + //TODO : 테스트를 강화할 필요가 있음 + val reviewId1 = + mvc.perform( + post("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $userAccessToken") + .content( + mapper.writeValueAsString( + mapOf( + "rating" to 5, + "title" to "Great Performance!", + "content" to "Absolutely amazing. Highly recommend!", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("id").asText() } + + val reviewId2 = + mvc.perform( + post("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $userAccessToken") + .content( + mapper.writeValueAsString( + mapOf( + "rating" to 5, + "title" to "Great Performance!", + "content" to "Absolutely amazing. Highly recommend!", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("id").asText() } + + mvc.perform( + get("/api/v2/performance/$performanceId/review") + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$.data[?(@.id == '$reviewId1')]").exists()) + .andExpect(jsonPath("$.data[?(@.id == '$reviewId2')]").exists()) + + } } diff --git a/src/test/resources/GetApiTest.http b/src/test/resources/GetApiTest.http index 72de84a..54b9d04 100644 --- a/src/test/resources/GetApiTest.http +++ b/src/test/resources/GetApiTest.http @@ -14,3 +14,38 @@ Accept: application/json GET http://localhost/api/v1/performance/search?cursor=MDkxZTY3YWItNDRiOS00NjlmLTk1NGYtMWFmZjhmNjcwYjI2LDA5MWU2N2FiLTQ0YjktNDY5Zi05NTRmLTFhZmY4ZjY3MGIyNg== Accept: application/json +### 회원가입(USER) +POST http://localhost:80/api/v1/local/signup +Content-Type: application/json + +{ + "username": "correct", + "password": "12345678", + "nickname": "examplename", + "phoneNumber": "010-0000-0000", + "email": "test@example.com" +} + +### 로그인 +POST http://localhost:80/api/v1/local/signin +Content-Type: application/json + +{ + "username": "correct", + "password": "12345678" +} + +### 댓글 쓰기 +POST http://localhost/api/v1/performance/f1c7da85-c5a3-4c38-b3d6-993ef245ab84/review +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyOTRlYTU0OS1hMTQxLTRmMTktYWE0NC05MGIyODI3Nzg0YTYiLCJpYXQiOjE3MzgyMTY3NTQsImV4cCI6MTczODIxNzY1NH0.931yggANm302o8VZbENqFNQDbJ-k1FlbMzeFoFS2HQo +Content-Type: application/json + +{ + "rating": 5, + "title": "Good", + "content": "very good" +} + +### 댓글 조회 +GET http://localhost/api/v2/performance/f1c7da85-c5a3-4c38-b3d6-993ef245ab84/review +Content-Type: application/json From 86ac0013e6468d210b85bcc5ee93b6f326ec2e6e Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Thu, 30 Jan 2025 15:32:25 +0900 Subject: [PATCH 110/162] social login receive socialAccessToken, not authorizatoinCode --- .../interpark/user/controller/SocialAuthController.kt | 6 +++--- .../interpark/user/service/SocialAuthService.kt | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt index 881d472..32b59de 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt @@ -71,12 +71,12 @@ class SocialAuthController( @Parameter(description = "소셜 로그인 제공자 (예: KAKAO, NAVER)", example = "KAKAO") @PathVariable provider: Provider, - @Parameter(description = "인가 코드", example = "4/P7q7W91a-oMsCeLvIaQm6bTrgtp7") - @RequestParam("code") authorizationCode: String, + @Parameter(description = "소셜 인증 서버 액세스 토큰", example = "4/P7q7W91a-oMsCeLvIaQm6bTrgtp7") + @RequestParam("code") socialAccessToken: String, response: HttpServletResponse ): ResponseEntity { - val result = socialAuthService.socialLogin(provider, authorizationCode) + val result = socialAuthService.socialLogin(provider, socialAccessToken) val (user, accessToken, refreshToken, providerId) = result val cookie = Cookie("refreshToken", refreshToken).apply { diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/SocialAuthService.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/SocialAuthService.kt index b022abf..95d7f4d 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/service/SocialAuthService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/SocialAuthService.kt @@ -235,13 +235,13 @@ class SocialAuthService( @Transactional fun socialLogin( provider: Provider, - code: String, + accessToken: String, ) : SocialLoginResult { // (1) code -> token - val token = exchangeCodeForToken(provider, code) + //val token = exchangeCodeForToken(provider, code) // (2) token -> userInfo - val userInfo = getUserInfo(provider, token.accessToken) + val userInfo = getUserInfo(provider, accessToken) // (3) DB에서 "이미 존재하는" 소셜 계정 찾기 val socialAccount = socialAccountRepository.findByProviderAndProviderId(provider, userInfo.providerId) From 5357a771a93c01ea18a938ceafcec9dabbc617e4 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Thu, 30 Jan 2025 15:52:49 +0900 Subject: [PATCH 111/162] fix: Review Cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기능 완성 --- .../interpark/pagination/CursorException.kt | 18 +++++++++++++ .../interpark/pagination/CursorPageService.kt | 14 ++++++++-- .../wafflestudio/interpark/PaginationTest.kt | 26 ++++++++++++------- src/test/resources/GetApiTest.http | 16 +++++++++--- 4 files changed, 59 insertions(+), 15 deletions(-) create mode 100644 src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt new file mode 100644 index 0000000..7e7847d --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt @@ -0,0 +1,18 @@ +package com.wafflestudio.interpark.pagination + +import com.wafflestudio.interpark.DomainException +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatusCode + +sealed class CursorException( + errorCode: Int, + httpStatusCode: HttpStatusCode, + msg: String, + cause: Throwable? = null, +) : DomainException(errorCode, httpStatusCode, msg, cause) + +class InvalidFieldNameException : CursorException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "Invalid field name", +) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt index 0ed894e..b5a63de 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt @@ -5,6 +5,7 @@ import org.springframework.data.domain.Sort import org.springframework.data.jpa.domain.Specification import org.springframework.data.jpa.repository.JpaSpecificationExecutor import java.awt.Cursor +import java.time.Instant abstract class CursorPageService( private val repository: JpaSpecificationExecutor @@ -15,8 +16,17 @@ abstract class CursorPageService( ): CursorPageResponse { val cursor = cursorPageable.decodeCursor() + val parsedCursor = cursor?.let { + val parsedFieldCursor = when(cursorPageable.sortFieldName) { + "createdAt" -> cursor.let { Instant.parse(cursor.first) } + "id" -> cursor.first + else -> throw InvalidFieldNameException() + } + parsedFieldCursor to cursor.second + } + val cursorSpec = CursorSpecification.withCursor( - cursor = cursor, + cursor = parsedCursor, sortFieldName = cursorPageable.sortFieldName, isDescending = cursorPageable.isDescending, ) @@ -52,7 +62,7 @@ data class CursorPageable( val isDescending: Boolean = true, val size: Int = 5, ) { - fun decodeCursor(): Pair? { + fun decodeCursor(): Pair? { return cursor?.let {CursorEncoder.decodeCursor(it) } } } diff --git a/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt index 87b3e9a..fabddd7 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt @@ -173,31 +173,39 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it).get("id").asText() } - val reviewId2 = + (1..5).forEach { mvc.perform( post("/api/v1/performance/$performanceId/review") .header("Authorization", "Bearer $userAccessToken") .content( mapper.writeValueAsString( mapOf( - "rating" to 5, - "title" to "Great Performance!", + "rating" to it, + "title" to "Great Performance! $it", "content" to "Absolutely amazing. Highly recommend!", ), ), ) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().`is`(201)) - .andReturn() - .response - .getContentAsString(Charsets.UTF_8) - .let { mapper.readTree(it).get("id").asText() } + } + + val response = mvc.perform( + get("/api/v2/performance/$performanceId/review") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + + val hasNext = response.get("hasNext").asBoolean() + assert(hasNext) {"expected hasNext true but false"} + val cursor = response.get("nextCursor").asText() mvc.perform( get("/api/v2/performance/$performanceId/review") + .apply { param("cursor", cursor) } ).andExpect(status().`is`(200)) .andExpect(jsonPath("$.data[?(@.id == '$reviewId1')]").exists()) - .andExpect(jsonPath("$.data[?(@.id == '$reviewId2')]").exists()) - } } diff --git a/src/test/resources/GetApiTest.http b/src/test/resources/GetApiTest.http index 54b9d04..1b0f06c 100644 --- a/src/test/resources/GetApiTest.http +++ b/src/test/resources/GetApiTest.http @@ -36,16 +36,24 @@ Content-Type: application/json } ### 댓글 쓰기 -POST http://localhost/api/v1/performance/f1c7da85-c5a3-4c38-b3d6-993ef245ab84/review -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyOTRlYTU0OS1hMTQxLTRmMTktYWE0NC05MGIyODI3Nzg0YTYiLCJpYXQiOjE3MzgyMTY3NTQsImV4cCI6MTczODIxNzY1NH0.931yggANm302o8VZbENqFNQDbJ-k1FlbMzeFoFS2HQo +POST http://localhost/api/v1/performance/f8d31155-8243-48a8-a060-3306b52fa227/review +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0ZjhmZWQ0OC1mMzUyLTRlNzItOWFjYi05ZTUzZDc2YWNlMzYiLCJpYXQiOjE3MzgyMTk4MDMsImV4cCI6MTczODIyMDcwM30.lX6Ory8LVzsc494R1_q_OtMJSRWpsVvWV-Jo8DysR7M Content-Type: application/json { "rating": 5, - "title": "Good", + "title": "3rd Good", "content": "very good" } ### 댓글 조회 -GET http://localhost/api/v2/performance/f1c7da85-c5a3-4c38-b3d6-993ef245ab84/review +GET http://localhost/api/v2/performance/f8d31155-8243-48a8-a060-3306b52fa227/review +Content-Type: application/json + +### 커서와 함께 댓글 조회 +GET http://localhost/api/v2/performance/f8d31155-8243-48a8-a060-3306b52fa227/review?cursor=MjAyNS0wMS0zMFQwNjo1MDo0MC45MzU1OTBaLGE1NzgxMTU4LWE3ODEtNGE5Ni1hNDM2LTA1MjY3MzQ5OWE4NQ== +Content-Type: application/json + +### 커서와 함께 한번더 댓글 조회 +GET http://localhost/api/v2/performance/f8d31155-8243-48a8-a060-3306b52fa227/review?cursor=MjAyNS0wMS0zMFQwNjo1MDozMy4wMDY4NjdaLDIwZGU2NWE2LThkNWYtNDE2My1iOTkzLWExMjIyYjFjNGMzZQ== Content-Type: application/json From 576821f141b07dae35c4d53e3b766349b7fb6529 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Thu, 30 Jan 2025 16:07:03 +0900 Subject: [PATCH 112/162] =?UTF-8?q?feat:=20Reply=EC=97=90=EB=8F=84=20Curso?= =?UTF-8?q?r=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interpark/pagination/CursorException.kt | 1 + .../review/controller/ReplyController.kt | 13 +++++++++ .../review/controller/ReviewController.kt | 2 +- .../review/persistence/ReplyRepository.kt | 4 ++- .../interpark/review/service/ReplyService.kt | 29 ++++++++++++++++++- 5 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt index 7e7847d..872660d 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt @@ -12,6 +12,7 @@ sealed class CursorException( ) : DomainException(errorCode, httpStatusCode, msg, cause) class InvalidFieldNameException : CursorException( + //TODO: exception 늘려서 제대로 처리하기 errorCode = 0, httpStatusCode = HttpStatus.BAD_REQUEST, msg = "Invalid field name", diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt index 5fac5ff..3824490 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt @@ -1,5 +1,7 @@ package com.wafflestudio.interpark.review.controller +import com.wafflestudio.interpark.pagination.CursorPageResponse +import com.wafflestudio.interpark.pagination.CursorPageable import com.wafflestudio.interpark.review.service.ReplyService import com.wafflestudio.interpark.user.controller.UserDetailsImpl import org.springframework.http.ResponseEntity @@ -26,6 +28,16 @@ class ReplyController( return ResponseEntity.ok(replies) } + @GetMapping("/api/v1/review/{reviewId}/reply") + fun getCursorReplies( + @PathVariable reviewId: String, + @RequestParam cursor: String?, + ): ResponseEntity{ + val cursorPageable= CursorPageable(sortFieldName = "createdAt", cursor = cursor) + val reviews = replyService.getRepliesWithCursor(reviewId, cursorPageable) + return ResponseEntity.ok(reviews) + } + @PostMapping("/api/v1/review/{reviewId}/reply") fun createReply( @RequestBody request: CreateReplyRequest, @@ -60,6 +72,7 @@ class ReplyController( typealias GetReplyResponse = List +typealias GetCursorReplyResponse = CursorPageResponse data class CreateReplyRequest( val content: String, diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt index 1bb3170..42a112c 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt @@ -32,7 +32,7 @@ class ReviewController( } @GetMapping("/api/v2/performance/{performanceId}/review") - fun getReviews( + fun getCursorReviews( @PathVariable performanceId: String, @RequestParam cursor: String?, ): ResponseEntity{ diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt index befd9a5..d49d831 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt @@ -1,9 +1,11 @@ package com.wafflestudio.interpark.review.persistence import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.JpaSpecificationExecutor import org.springframework.data.jpa.repository.Query -interface ReplyRepository : JpaRepository { +interface ReplyRepository : JpaRepository, + JpaSpecificationExecutor { @Query("SELECT r FROM ReplyEntity r WHERE r.review.id = :reviewId ORDER BY r.createdAt DESC") fun findByReviewId(reviewId: String): List @Query("SELECT r FROM ReplyEntity r WHERE r.author.id = :authorId ORDER BY r.createdAt DESC") diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt index 3db7ebf..6711b11 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt @@ -1,14 +1,21 @@ package com.wafflestudio.interpark.review.service +import com.wafflestudio.interpark.pagination.CursorPageResponse +import com.wafflestudio.interpark.pagination.CursorPageService +import com.wafflestudio.interpark.pagination.CursorPageable +import com.wafflestudio.interpark.performance.persistence.PerformanceEntity import com.wafflestudio.interpark.review.* import com.wafflestudio.interpark.review.persistence.ReviewRepository import com.wafflestudio.interpark.review.controller.Reply +import com.wafflestudio.interpark.review.controller.Review import com.wafflestudio.interpark.review.persistence.ReplyEntity import com.wafflestudio.interpark.review.persistence.ReplyRepository +import com.wafflestudio.interpark.review.persistence.ReviewEntity import com.wafflestudio.interpark.user.AuthenticateException import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.persistence.UserRepository import jakarta.persistence.EntityManager +import org.springframework.data.jpa.domain.Specification import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -20,7 +27,7 @@ class ReplyService( private val reviewRepository: ReviewRepository, private val replyRepository: ReplyRepository, private val userRepository: UserRepository, -) { +) : CursorPageService(replyRepository) { fun getRepliesByUser(userId: String): List { val replies: List = @@ -38,6 +45,26 @@ class ReplyService( return replies } + fun getRepliesWithCursor( + reviewId: String, + cursorPageable: CursorPageable, + ): CursorPageResponse { + val spec: Specification = Specification.where { root, _, cb -> + cb.equal(root.get("review").get("id"), reviewId) + } + + val searchResult = findAllWithCursor(cursorPageable, spec) + val replyEntities = searchResult.data + + val replyData = replyEntities.map { Reply.fromEntity(it) } + + return CursorPageResponse( + data = replyData, + nextCursor = searchResult.nextCursor, + hasNext = searchResult.hasNext, + ) + } + fun countReplies(reviewId: String): Int { val replyCount = replyRepository.countByReviewId(reviewId) return replyCount From 15f2d39ab4753658593e61081e17554915702769 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Thu, 30 Jan 2025 16:08:09 +0900 Subject: [PATCH 113/162] =?UTF-8?q?fix:=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wafflestudio/interpark/review/controller/ReplyController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt index 3824490..87d0151 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt @@ -28,7 +28,7 @@ class ReplyController( return ResponseEntity.ok(replies) } - @GetMapping("/api/v1/review/{reviewId}/reply") + @GetMapping("/api/v2/review/{reviewId}/reply") fun getCursorReplies( @PathVariable reviewId: String, @RequestParam cursor: String?, From 432057ed5631cca6db881b672607e6130f0e0778 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Thu, 30 Jan 2025 16:25:21 +0900 Subject: [PATCH 114/162] =?UTF-8?q?feat:=20performance=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1과 v2로 나누어 선택할 수 있도록 --- .../controller/PerformanceController.kt | 17 ++++++-- .../performance/service/PerformanceService.kt | 40 +++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index 65ce0f2..3f72899 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -26,12 +26,21 @@ class PerformanceController( fun searchPerformance( @RequestParam title: String?, @RequestParam category: PerformanceCategory?, - @RequestParam cursor: String?, ): ResponseEntity { + val queriedPerformances = performanceService.searchPerformance(title, category) + return ResponseEntity.ok(queriedPerformances) + } + + @GetMapping("/api/v2/performance/search") + fun searchCursorPerformance( + @RequestParam title: String?, + @RequestParam category: PerformanceCategory?, + @RequestParam cursor: String?, + ): ResponseEntity { // @RequestParam(defaultValue) 대신 데이터 클래스 내부적으로 기본값 처리 val cursorPageable= CursorPageable(cursor = cursor) - val queriedPerformances = performanceService.searchPerformance(title, category, cursorPageable) + val queriedPerformances = performanceService.searchPerformanceWithCursor(title, category, cursorPageable) return ResponseEntity.ok(queriedPerformances) } @@ -77,7 +86,9 @@ class PerformanceController( } -typealias SearchPerformanceResponse = CursorPageResponse +typealias SearchPerformanceResponse = List + +typealias SearchCursorPerformanceResponse = CursorPageResponse data class BriefPerformanceDetail( val id: String, diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt index 676e6d9..c46e066 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt @@ -22,6 +22,46 @@ class PerformanceService( fun searchPerformance( title: String?, category: PerformanceCategory?, + ): List { + // 시작점: 아무 조건이 없는 스펙 + var spec: Specification = Specification.where(null) + + // title 조건이 있다면 스펙에 and로 연결 + PerformanceSpecifications.withTitle(title)?.let { + spec = spec.and(it) + } + + // category 조건이 있다면 스펙에 and로 연결 + PerformanceSpecifications.withCategory(category)?.let { + spec = spec.and(it) + } + + // 스펙이 결국 아무 조건도 없으면 -> 전체 검색 + val performanceEntities = performanceRepository.findAll(spec) + + // BriefDetail DTO 변환 + return performanceEntities.map { performanceEntity -> + val performanceEventEntities = performanceEventRepository.findAllByPerformanceId(performanceEntity.id!!) + val performanceEvents = if (performanceEventEntities.isEmpty()) { + null + } else { + performanceEventEntities.map { PerformanceEvent.fromEntity(it) } + } + val performanceHall = performanceEventEntities.firstOrNull()?.let { + PerformanceHall.fromEntity(it.performanceHall) + } + + Performance.fromEntityToBriefDetails( + performanceEntity = performanceEntity, + performanceHall = performanceHall, + performanceEvents = performanceEvents + ) + } + } + + fun searchPerformanceWithCursor( + title: String?, + category: PerformanceCategory?, cursorPageable: CursorPageable, ): CursorPageResponse { // 시작점: 아무 조건이 없는 스펙 From a24a33db55cf07bc6d3fe1cea18ce4a7d8583a48 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Thu, 30 Jan 2025 16:55:10 +0900 Subject: [PATCH 115/162] feat: permitAll for pagination --- .../com/wafflestudio/interpark/security/SecurityConfig.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt index f261ee1..759c05a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt @@ -26,6 +26,7 @@ class SecurityConfig ( authorizeHttpRequests { // 사용자 권한 authorize(HttpMethod.GET, "/api/v1/performance/search", permitAll) // 공연 조회 + authorize(HttpMethod.GET, "/api/v2/performance/search", permitAll) // 공연 조회 + 페이지네이션 authorize(HttpMethod.GET, "/api/v1/performance/{performanceId}", permitAll) // 공연 상세정보 반환 authorize(HttpMethod.GET, "/api/v1/performance-event", permitAll) authorize(HttpMethod.GET, "/api/v1/performance-event/{performanceId}/{performanceDate}", permitAll) @@ -39,6 +40,7 @@ class SecurityConfig ( authorize(HttpMethod.GET, "/api/v1/performance/{performanceId}/review", permitAll) authorize(HttpMethod.GET, "/api/v2/performance/{performanceId}/review", permitAll) authorize(HttpMethod.GET, "/api/v1/review/{reviewId}/reply", permitAll) + authorize(HttpMethod.GET, "/api/v2/review/{reviewId}/reply", permitAll) authorize("/api/v1/**", hasAnyRole("USER", "ADMIN")) // 그 외 모두 유저 권한 필요 authorize("/admin/v1/**", hasRole("ADMIN")) From 4127191952a7f6bb215d4ec3358bb829ebd46d13 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Thu, 30 Jan 2025 17:01:18 +0900 Subject: [PATCH 116/162] =?UTF-8?q?fix:=20test=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/wafflestudio/interpark/PaginationTest.kt | 10 +++++----- .../interpark/PerformanceIntegrationTest.kt | 6 +++--- .../com/wafflestudio/interpark/ReplyIntegrationTest.kt | 2 +- .../wafflestudio/interpark/ReviewIntegrationTest.kt | 2 +- .../interpark/ReviewLikeIntegrationTest.kt | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt index fabddd7..9be7146 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt @@ -75,7 +75,7 @@ constructor( // 3️⃣ 테스트용 공연 ID 반환 performanceId = mvc.perform( - get("/api/v1/performance/search") + get("/api/v2/performance/search") .header("Authorization", "Bearer $userAccessToken") .param("title", "지킬앤하이드") .contentType(MediaType.APPLICATION_JSON), @@ -100,7 +100,7 @@ constructor( while(iterations < maxIteration) { val response = mvc.perform( - get("/api/v1/performance/search") + get("/api/v2/performance/search") .apply { cursor?.let { param("cursor", it) } } .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().`is`(200)) @@ -128,7 +128,7 @@ constructor( while(iterations < maxIteration) { val response = mvc.perform( - get("/api/v1/performance/search") + get("/api/v2/performance/search") .param("category", PerformanceCategory.CONCERT.name) .apply { cursor?.let { param("cursor", it) } } .contentType(MediaType.APPLICATION_JSON), @@ -155,7 +155,7 @@ constructor( //TODO : 테스트를 강화할 필요가 있음 val reviewId1 = mvc.perform( - post("/api/v1/performance/$performanceId/review") + post("/api/v2/performance/$performanceId/review") .header("Authorization", "Bearer $userAccessToken") .content( mapper.writeValueAsString( @@ -175,7 +175,7 @@ constructor( (1..5).forEach { mvc.perform( - post("/api/v1/performance/$performanceId/review") + post("/api/v2/performance/$performanceId/review") .header("Authorization", "Bearer $userAccessToken") .content( mapper.writeValueAsString( diff --git a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt index 9a233a2..ee38d61 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt @@ -112,7 +112,7 @@ constructor( // 3️⃣ 테스트용 공연 ID 반환 performanceId = mvc.perform( - get("/api/v1/performance/search") + get("/api/v2/performance/search") .header("Authorization", "Bearer $userAccessToken") .param("title", "지킬앤하이드") .contentType(MediaType.APPLICATION_JSON), @@ -133,7 +133,7 @@ constructor( fun `공연 검색 플로우 테스트`() { // 4️⃣ 공연 검색 (title 조건) mvc.perform( - get("/api/v1/performance/search") + get("/api/v2/performance/search") .header("Authorization", "Bearer $userAccessToken") .param("title", "지킬앤하이드") .contentType(MediaType.APPLICATION_JSON), @@ -143,7 +143,7 @@ constructor( // 5️⃣ 공연 검색 (category 조건) mvc.perform( - get("/api/v1/performance/search") + get("/api/v2/performance/search") .header("Authorization", "Bearer $userAccessToken") .param("category", PerformanceCategory.CONCERT.name) .contentType(MediaType.APPLICATION_JSON), diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt index 6d9fb55..0ce92da 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt @@ -73,7 +73,7 @@ class ReplyIntegrationTest //테스트 용으로 아무 공연 Id를 하나 가져온다 performanceId = mvc.perform( - get("/api/v1/performance/search") + get("/api/v2/performance/search") ).andExpect(status().`is`(200)) .andReturn() .response diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt index fdde46e..4ef26bc 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt @@ -73,7 +73,7 @@ class ReviewIntegrationTest //테스트 용으로 아무 공연 Id를 하나 가져온다 performanceId = mvc.perform( - get("/api/v1/performance/search") + get("/api/v2/performance/search") ).andExpect(status().`is`(200)) .andReturn() .response diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt index 359e2c7..7ef2b38 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt @@ -106,7 +106,7 @@ constructor( //테스트 용으로 아무 공연 Id를 하나 가져온다 performanceId = mvc.perform( - get("/api/v1/performance/search") + get("/api/v2/performance/search") ).andExpect(status().`is`(200)) .andReturn() .response From 93c11723fdee7cb6033f0ea04b5065225c0b617b Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Thu, 30 Jan 2025 17:04:26 +0900 Subject: [PATCH 117/162] =?UTF-8?q?fix:=20test=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt index 9be7146..05337db 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt @@ -155,7 +155,7 @@ constructor( //TODO : 테스트를 강화할 필요가 있음 val reviewId1 = mvc.perform( - post("/api/v2/performance/$performanceId/review") + post("/api/v1/performance/$performanceId/review") .header("Authorization", "Bearer $userAccessToken") .content( mapper.writeValueAsString( @@ -175,7 +175,7 @@ constructor( (1..5).forEach { mvc.perform( - post("/api/v2/performance/$performanceId/review") + post("/api/v1/performance/$performanceId/review") .header("Authorization", "Bearer $userAccessToken") .content( mapper.writeValueAsString( From f57a99b62105aeab265b801a872845ff849b6df6 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Thu, 30 Jan 2025 17:09:29 +0900 Subject: [PATCH 118/162] =?UTF-8?q?feat:=20Performance=20Search=20&=20Get?= =?UTF-8?q?=20Review=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=A0=81=EC=9A=A9=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/GetApiTest.http | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/test/resources/GetApiTest.http b/src/test/resources/GetApiTest.http index 1b0f06c..cc2fa45 100644 --- a/src/test/resources/GetApiTest.http +++ b/src/test/resources/GetApiTest.http @@ -1,17 +1,17 @@ ### performance 받기 -GET http://localhost/api/v1/performance/search +GET http://localhost/api/v2/performance/search Accept: application/json ### 다음 cursor로 요청 -GET http://localhost/api/v1/performance/search?cursor=NzBkOTlhYTktNGI3My00YWMzLTg2NDQtMzE0ZjM3M2EwZThlLDcwZDk5YWE5LTRiNzMtNGFjMy04NjQ0LTMxNGYzNzNhMGU4ZQ== +GET http://localhost/api/v2/performance/search?cursor=OGE1YzU0MjctY2IzOC00OTMzLWFkZGYtOWU2NTQxOTkwNGU0LDhhNWM1NDI3LWNiMzgtNDkzMy1hZGRmLTllNjU0MTk5MDRlNA== Accept: application/json ### 다음 cursor로 요청 -GET http://localhost/api/v1/performance/search?cursor=MGY5NGY1MTAtZDMxYS00MjdmLTg4M2YtM2UyMGRmMWIyNDkyLDBmOTRmNTEwLWQzMWEtNDI3Zi04ODNmLTNlMjBkZjFiMjQ5Mg== +GET http://localhost/api/v2/performance/search?cursor=MjRkMWQwZGItNzIxYi00OWE1LWE1MmMtMjc2ZDBmODg1N2M3LDI0ZDFkMGRiLTcyMWItNDlhNS1hNTJjLTI3NmQwZjg4NTdjNw== Accept: application/json ### 다음 cursor로 요청 -GET http://localhost/api/v1/performance/search?cursor=MDkxZTY3YWItNDRiOS00NjlmLTk1NGYtMWFmZjhmNjcwYjI2LDA5MWU2N2FiLTQ0YjktNDY5Zi05NTRmLTFhZmY4ZjY3MGIyNg== +GET http://localhost/api/v2/performance/search?cursor=MDcwYjkwN2EtZGM1ZS00ZmU0LTgzNDEtZDIyYWY1ZWQ3MmNiLDA3MGI5MDdhLWRjNWUtNGZlNC04MzQxLWQyMmFmNWVkNzJjYg== Accept: application/json ### 회원가입(USER) @@ -36,24 +36,24 @@ Content-Type: application/json } ### 댓글 쓰기 -POST http://localhost/api/v1/performance/f8d31155-8243-48a8-a060-3306b52fa227/review -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0ZjhmZWQ0OC1mMzUyLTRlNzItOWFjYi05ZTUzZDc2YWNlMzYiLCJpYXQiOjE3MzgyMTk4MDMsImV4cCI6MTczODIyMDcwM30.lX6Ory8LVzsc494R1_q_OtMJSRWpsVvWV-Jo8DysR7M +POST http://localhost/api/v1/performance/d2f2bf55-be48-4981-b92a-b2c6aa181826/review +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI3NWNmMjQ2Ni1hMzdmLTQ3MjktYmJhMi1hNWVlZTRhYmNkNDIiLCJpYXQiOjE3MzgyMjQ0MTMsImV4cCI6MTczODIyNTMxM30.YhLLhe4FpL4PYA_7lPDxtPcnvnfBwF5DiCEdJSM5pjw Content-Type: application/json { - "rating": 5, - "title": "3rd Good", + "rating": 3, + "title": "1st Bad Good", "content": "very good" } ### 댓글 조회 -GET http://localhost/api/v2/performance/f8d31155-8243-48a8-a060-3306b52fa227/review +GET http://localhost/api/v2/performance/d2f2bf55-be48-4981-b92a-b2c6aa181826/review Content-Type: application/json ### 커서와 함께 댓글 조회 -GET http://localhost/api/v2/performance/f8d31155-8243-48a8-a060-3306b52fa227/review?cursor=MjAyNS0wMS0zMFQwNjo1MDo0MC45MzU1OTBaLGE1NzgxMTU4LWE3ODEtNGE5Ni1hNDM2LTA1MjY3MzQ5OWE4NQ== +GET http://localhost/api/v2/performance/d2f2bf55-be48-4981-b92a-b2c6aa181826/review?cursor=MjAyNS0wMS0zMFQwODowNzo0Ny4wOTE5NTVaLDg4YTgyZDA2LTdlZDQtNGM0NC05ZmJjLWUwYzZjNzlkNjE3YQ== Content-Type: application/json ### 커서와 함께 한번더 댓글 조회 -GET http://localhost/api/v2/performance/f8d31155-8243-48a8-a060-3306b52fa227/review?cursor=MjAyNS0wMS0zMFQwNjo1MDozMy4wMDY4NjdaLDIwZGU2NWE2LThkNWYtNDE2My1iOTkzLWExMjIyYjFjNGMzZQ== +GET http://localhost/api/v2/performance/f8d31155-8243-48a8-a060-3306b52fa227/review?cursor=MjAyNS0wMS0zMFQwODowNzo0MC44NTQzMzhaLDgzODI0Njg4LWIzMGEtNGZmZi1iOThjLTgzNmRlOTNhMjMxYg== Content-Type: application/json From 975ae221fa08b161a11416c58437adcaa7601d9e Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Fri, 31 Jan 2025 12:32:09 +0900 Subject: [PATCH 119/162] modify makefile / test: h2 -> mysql --- .env | 3 --- Makefile | 1 + 2 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 .env diff --git a/.env b/.env deleted file mode 100644 index e45ba25..0000000 --- a/.env +++ /dev/null @@ -1,3 +0,0 @@ -SPRING_DATASOURCE_URL: "jdbc:mysql://mysql-db:3306/testdb" -SPRING_DATASOURCE_USERNAME: "user" -SPRING_DATASOURCE_PASSWORD: "somepassword" \ No newline at end of file diff --git a/Makefile b/Makefile index cc095ee..6a960d3 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ else endif all: + docker compose up -d mysql $(GRADLE_CMD) build docker build -t myapp:1.0 . docker compose up \ No newline at end of file From 093d973a1417a4c0129771119ff642cb5cc760fc Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Fri, 31 Jan 2025 14:50:05 +0900 Subject: [PATCH 120/162] modify social login explanation --- .../interpark/user/controller/SocialAuthController.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt index 32b59de..9ceb650 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt @@ -26,8 +26,8 @@ class SocialAuthController( @Operation( summary = "소셜 로그인 요청", description = """ - 클라이언트가 인가코드와 provider(ex. KAKAO, NAVER)를 요청에 담아 소셜로그인을 요청합니다. - 서버에서는 인가 코드를 통해 소셜 인증 서버에 액세스 토큰을 요청하고 이를 다시 소셜 계정 정보와 교환합니다. + 클라이언트가 소셜 서버 엑세스 토큰과 provider(ex. KAKAO, NAVER)를 요청에 담아 소셜로그인을 요청합니다. + 서버에서는 엑세스 토큰을 소셜 계정 정보와 교환합니다. - 로그인한 소셜 계정이 로컬 계정과 연동되어 있는 계정인 경우 기존 로컬 로그인 응답 객체에 provider와 providerId를 추가로 담아 반환합니다. @@ -71,8 +71,8 @@ class SocialAuthController( @Parameter(description = "소셜 로그인 제공자 (예: KAKAO, NAVER)", example = "KAKAO") @PathVariable provider: Provider, - @Parameter(description = "소셜 인증 서버 액세스 토큰", example = "4/P7q7W91a-oMsCeLvIaQm6bTrgtp7") - @RequestParam("code") socialAccessToken: String, + @Parameter(description = "소셜 인증 서버 액세스 토큰") + @RequestParam("token") socialAccessToken: String, response: HttpServletResponse ): ResponseEntity { From 8d5dc557105ac3db9280da07c5d6b57af3d899cf Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Fri, 31 Jan 2025 16:44:32 +0900 Subject: [PATCH 121/162] =?UTF-8?q?feat:=20Exception=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interpark/pagination/CursorException.kt | 19 +++++++++++++++- .../interpark/pagination/CursorPageService.kt | 2 +- .../pagination/CursorSpecification.kt | 22 ++++++++++++++----- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt index 872660d..ccda7e1 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt @@ -11,9 +11,26 @@ sealed class CursorException( cause: Throwable? = null, ) : DomainException(errorCode, httpStatusCode, msg, cause) +class InvalidCursorException : CursorException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "Failed to decode Cursor", +) + class InvalidFieldNameException : CursorException( - //TODO: exception 늘려서 제대로 처리하기 errorCode = 0, httpStatusCode = HttpStatus.BAD_REQUEST, msg = "Invalid field name", +) + +class FieldNotFoundException : CursorException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "Field not found", +) + +class CursorNotComparableException : CursorException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "Cursor not comparable", ) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt index b5a63de..e4064a1 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt @@ -63,7 +63,7 @@ data class CursorPageable( val size: Int = 5, ) { fun decodeCursor(): Pair? { - return cursor?.let {CursorEncoder.decodeCursor(it) } + return cursor?.let { CursorEncoder.decodeCursor(it) ?: throw InvalidCursorException() } } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorSpecification.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorSpecification.kt index 6759b49..656260e 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorSpecification.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorSpecification.kt @@ -10,16 +10,26 @@ object CursorSpecification { isDescending: Boolean = true, ): Specification? { if (cursor == null) return null - val fieldCursor = cursor.first - val idCursor = cursor.second + val fieldCursor = cursor.first as? Comparable + ?: throw CursorNotComparableException() + val idCursor = cursor.second as? String + ?: throw CursorNotComparableException() return Specification {root, _, cb -> - val fieldPath = root.get>(sortFieldName) - val idPath = root.get("id") + val fieldPath = try { + root.get>(sortFieldName) + } catch (e: IllegalArgumentException) { + throw FieldNotFoundException() + } + val idPath = try { + root.get("id") + } catch (e: IllegalArgumentException) { + throw FieldNotFoundException() + } if(isDescending) { cb.or( - cb.lessThan(fieldPath, fieldCursor as Comparable), + cb.lessThan(fieldPath, fieldCursor), cb.and( cb.equal(fieldPath, fieldCursor), cb.lessThan(idPath, idCursor) @@ -28,7 +38,7 @@ object CursorSpecification { } else { cb.or( - cb.greaterThan(fieldPath, fieldCursor as Comparable), + cb.greaterThan(fieldPath, fieldCursor), cb.and( cb.equal(fieldPath, fieldCursor), cb.greaterThan(idPath, idCursor) From ee56ae0cd7e7f9b15ec9dec4fbc58102b21a0296 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Fri, 31 Jan 2025 16:47:49 +0900 Subject: [PATCH 122/162] =?UTF-8?q?fix:=20=EA=B8=B0=EC=A1=B4=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/wafflestudio/interpark/PaginationTest.kt | 1 - .../interpark/PerformanceIntegrationTest.kt | 16 ++++++++-------- .../interpark/ReplyIntegrationTest.kt | 4 ++-- .../interpark/ReviewIntegrationTest.kt | 4 ++-- .../interpark/ReviewLikeIntegrationTest.kt | 4 ++-- 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt index 05337db..a85c6ca 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt @@ -31,7 +31,6 @@ constructor( @BeforeEach fun setUp() { val username = UUID.randomUUID().toString().take(8) - val adminname = UUID.randomUUID().toString().takeLast(8) val password = "password123" // 1️⃣ 회원가입 diff --git a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt index ee38d61..76e6b3e 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt @@ -112,7 +112,7 @@ constructor( // 3️⃣ 테스트용 공연 ID 반환 performanceId = mvc.perform( - get("/api/v2/performance/search") + get("/api/v1/performance/search") .header("Authorization", "Bearer $userAccessToken") .param("title", "지킬앤하이드") .contentType(MediaType.APPLICATION_JSON), @@ -122,7 +122,7 @@ constructor( .getContentAsString(Charsets.UTF_8) .let { val node = mapper.readTree(it) - val firstItem = node.get("data").firstOrNull() ?: error("Response array is empty") + val firstItem = node.firstOrNull() ?: error("Response array is empty") val idNode = firstItem.get("id") requireNotNull(idNode) { "ID not found in response item: $firstItem" } idNode.asText() @@ -133,23 +133,23 @@ constructor( fun `공연 검색 플로우 테스트`() { // 4️⃣ 공연 검색 (title 조건) mvc.perform( - get("/api/v2/performance/search") + get("/api/v1/performance/search") .header("Authorization", "Bearer $userAccessToken") .param("title", "지킬앤하이드") .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().`is`(200)) - .andExpect(jsonPath("$.data[0].title").value("뮤지컬 지킬앤하이드")) - .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$[0].title").value("뮤지컬 지킬앤하이드")) + .andExpect(jsonPath("$.length()").value(1)) // 5️⃣ 공연 검색 (category 조건) mvc.perform( - get("/api/v2/performance/search") + get("/api/v1/performance/search") .header("Authorization", "Bearer $userAccessToken") .param("category", PerformanceCategory.CONCERT.name) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().`is`(200)) - .andExpect(jsonPath("$.data").isArray) // 응답이 배열인지 확인 - .andExpect(jsonPath("$.data.length()").value(3)) // 배열의 길이가 0인지 확인 + .andExpect(jsonPath("$").isArray) // 응답이 배열인지 확인 + .andExpect(jsonPath("$.length()").value(3)) // 배열의 길이가 0인지 확인 } @Test diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt index 0ce92da..c8fe103 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt @@ -73,13 +73,13 @@ class ReplyIntegrationTest //테스트 용으로 아무 공연 Id를 하나 가져온다 performanceId = mvc.perform( - get("/api/v2/performance/search") + get("/api/v1/performance/search") ).andExpect(status().`is`(200)) .andReturn() .response .getContentAsString(Charsets.UTF_8) .let { - val performances = mapper.readTree(it).get("data") + val performances = mapper.readTree(it) performances[0].get("id").asText() } diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt index 4ef26bc..6367a7a 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt @@ -73,13 +73,13 @@ class ReviewIntegrationTest //테스트 용으로 아무 공연 Id를 하나 가져온다 performanceId = mvc.perform( - get("/api/v2/performance/search") + get("/api/v1/performance/search") ).andExpect(status().`is`(200)) .andReturn() .response .getContentAsString(Charsets.UTF_8) .let { - val performances = mapper.readTree(it).get("data") + val performances = mapper.readTree(it) performances[0].get("id").asText() } } diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt index 7ef2b38..ff570be 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewLikeIntegrationTest.kt @@ -106,13 +106,13 @@ constructor( //테스트 용으로 아무 공연 Id를 하나 가져온다 performanceId = mvc.perform( - get("/api/v2/performance/search") + get("/api/v1/performance/search") ).andExpect(status().`is`(200)) .andReturn() .response .getContentAsString(Charsets.UTF_8) .let { - val performances = mapper.readTree(it).get("data") + val performances = mapper.readTree(it) performances[0].get("id").asText() } From 8cee686ead6257881dee31d557aac0de295d8824 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Fri, 31 Jan 2025 17:16:24 +0900 Subject: [PATCH 123/162] =?UTF-8?q?feat:=20Reply=20Pagination=20=EC=99=84?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance, Review, Reply Pagination 구현 완료 --- .../wafflestudio/interpark/PaginationTest.kt | 118 +++++++++++++++++- src/test/resources/GetApiTest.http | 29 ++++- 2 files changed, 138 insertions(+), 9 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt index a85c6ca..cb41ce1 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt @@ -96,6 +96,7 @@ constructor( var cursor: String? = null val maxIteration = 4 var iterations = 0 + var totalItems = 0 while(iterations < maxIteration) { val response = mvc.perform( @@ -110,6 +111,8 @@ constructor( .let { mapper.readTree(it) } val hasNext = response.get("hasNext").asBoolean() + val dataSize = response.get("data").size() + totalItems += dataSize if(!hasNext) { break } @@ -117,6 +120,18 @@ constructor( iterations++ } + + // 전체 데이터를 다 가져왔는지 확인 + val totalSize = mvc.perform( + get("/api/v1/performance/search") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).size() } + + assert( totalItems == totalSize ) {"Expected $totalSize items but got $totalItems"} } @Test @@ -124,6 +139,7 @@ constructor( var cursor: String? = null val maxIteration = 4 var iterations = 0 + var totalItems = 0 while(iterations < maxIteration) { val response = mvc.perform( @@ -132,14 +148,14 @@ constructor( .apply { cursor?.let { param("cursor", it) } } .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().`is`(200)) - // CONCERT는 테스트 코드상에서 3개만 조회되어야 한다 - .andExpect(jsonPath("$.data.size()").value(3)) .andReturn() .response .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it) } val hasNext = response.get("hasNext").asBoolean() + val dataSize = response.get("data").size() + totalItems += dataSize if(!hasNext) { break } @@ -147,11 +163,32 @@ constructor( iterations++ } + + // 전체 데이터를 다 가져왔는지 확인 + val totalSize = mvc.perform( + get("/api/v1/performance/search") + .param("category", PerformanceCategory.CONCERT.name) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).size() } + + assert( totalItems == totalSize ) {"Expected $totalSize items but got $totalItems"} + } + + @Test + fun `잘못된 커서로 요청하면 오류`() { + mvc.perform( + get("/api/v2/performance/search") + .apply { param("cursor", "WrongCursor") } + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(400)) } @Test fun `공연의 리뷰 조회 페이지네이션 테스트`() { - //TODO : 테스트를 강화할 필요가 있음 val reviewId1 = mvc.perform( post("/api/v1/performance/$performanceId/review") @@ -201,10 +238,85 @@ constructor( assert(hasNext) {"expected hasNext true but false"} val cursor = response.get("nextCursor").asText() + // 가장 먼저 등록한 리뷰가 가장 마지막에 조회된다 mvc.perform( get("/api/v2/performance/$performanceId/review") .apply { param("cursor", cursor) } ).andExpect(status().`is`(200)) .andExpect(jsonPath("$.data[?(@.id == '$reviewId1')]").exists()) + .andExpect(jsonPath("$.hasNext").value(false)) + } + + @Test + fun `리뷰의 댓글 조회 페이지네이션 테스트`() { + val reviewId = + mvc.perform( + post("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $userAccessToken") + .content( + mapper.writeValueAsString( + mapOf( + "rating" to 5, + "title" to "Great Performance!", + "content" to "Absolutely amazing. Highly recommend!", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("id").asText() } + + val replyId1 = + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $userAccessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "First Reply"), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("id").asText() } + + (1..5).forEach { + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $userAccessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "$it"), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + } + + val response = mvc.perform( + get("/api/v2/review/$reviewId/reply") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + + val hasNext = response.get("hasNext").asBoolean() + assert(hasNext) {"expected hasNext true but false"} + + val cursor = response.get("nextCursor").asText() + // 가장 먼저 등록한 댓글이 가장 마지막에 조회된다 + mvc.perform( + get("/api/v2/review/$reviewId/reply") + .apply { param("cursor", cursor) } + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$.data[?(@.id == '$replyId1')]").exists()) + .andExpect(jsonPath("$.hasNext").value(false)) } } diff --git a/src/test/resources/GetApiTest.http b/src/test/resources/GetApiTest.http index cc2fa45..5aad715 100644 --- a/src/test/resources/GetApiTest.http +++ b/src/test/resources/GetApiTest.http @@ -35,9 +35,9 @@ Content-Type: application/json "password": "12345678" } -### 댓글 쓰기 -POST http://localhost/api/v1/performance/d2f2bf55-be48-4981-b92a-b2c6aa181826/review -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI3NWNmMjQ2Ni1hMzdmLTQ3MjktYmJhMi1hNWVlZTRhYmNkNDIiLCJpYXQiOjE3MzgyMjQ0MTMsImV4cCI6MTczODIyNTMxM30.YhLLhe4FpL4PYA_7lPDxtPcnvnfBwF5DiCEdJSM5pjw +### 리뷰 쓰기 +POST http://localhost/api/v1/performance/ec18db5d-09ab-47d0-8fec-46858a2780a7/review +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkMTQwYTU0Ni03ZDdiLTQ5OGItOWM4MS0zMTczYmQwODEzZjgiLCJpYXQiOjE3MzgzMTExNDUsImV4cCI6MTczODMxMjA0NX0.eR5Qh-B4h73reHCt82YWgpd8I1rhtdW8UIUajM1SGOE Content-Type: application/json { @@ -46,14 +46,31 @@ Content-Type: application/json "content": "very good" } -### 댓글 조회 +### 리뷰 조회 GET http://localhost/api/v2/performance/d2f2bf55-be48-4981-b92a-b2c6aa181826/review Content-Type: application/json -### 커서와 함께 댓글 조회 +### 커서와 함께 리뷰 조회 GET http://localhost/api/v2/performance/d2f2bf55-be48-4981-b92a-b2c6aa181826/review?cursor=MjAyNS0wMS0zMFQwODowNzo0Ny4wOTE5NTVaLDg4YTgyZDA2LTdlZDQtNGM0NC05ZmJjLWUwYzZjNzlkNjE3YQ== Content-Type: application/json -### 커서와 함께 한번더 댓글 조회 +### 커서와 함께 한번더 리뷰 조회 GET http://localhost/api/v2/performance/f8d31155-8243-48a8-a060-3306b52fa227/review?cursor=MjAyNS0wMS0zMFQwODowNzo0MC44NTQzMzhaLDgzODI0Njg4LWIzMGEtNGZmZi1iOThjLTgzNmRlOTNhMjMxYg== Content-Type: application/json + +### 댓글 쓰기 +POST http://localhost/api/v1/review/8dc698c8-26c2-4bbe-b548-33b9b0584d3a/reply +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkMTQwYTU0Ni03ZDdiLTQ5OGItOWM4MS0zMTczYmQwODEzZjgiLCJpYXQiOjE3MzgzMTExNDUsImV4cCI6MTczODMxMjA0NX0.eR5Qh-B4h73reHCt82YWgpd8I1rhtdW8UIUajM1SGOE +Content-Type: application/json + +{ + "content": "Agree7" +} + +### 댓글 조회 +GET http://localhost/api/v2/review/8dc698c8-26c2-4bbe-b548-33b9b0584d3a/reply +Content-Type: application/json + +### 커서와 함께 댓글 조회 +GET http://localhost/api/v2/review/8dc698c8-26c2-4bbe-b548-33b9b0584d3a/reply?cursor=MjAyNS0wMS0zMVQwODoxNToxMS41MzM0MDFaLDg2ZjFmNmRiLWMwNWEtNGIwOS04OWQwLWM3OWNlMDA1YTg4YQ== +Content-Type: application/json From 84a55bee6e537e6ddb206302c12313b70c253040 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Fri, 31 Jan 2025 19:37:37 +0900 Subject: [PATCH 124/162] =?UTF-8?q?feat:=20Performance=20Date=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interpark/config/DataInitializer.kt | 88 +++++++++---------- src/test/resources/SeatApi.http | 3 + 2 files changed, 45 insertions(+), 46 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index 004f0e6..3db37db 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -10,7 +10,10 @@ import com.wafflestudio.interpark.performance.service.PerformanceHallService import com.wafflestudio.interpark.performance.service.PerformanceService import org.springframework.boot.CommandLineRunner import org.springframework.context.annotation.Configuration +import java.time.LocalDate import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter @Configuration class DataInitializer( @@ -164,96 +167,62 @@ class DataInitializer( Triple( "뮤지컬 지킬앤하이드", "블루스퀘어 신한카드홀", - listOf( - listOf("2024-11-29T16:00:00", "2024-11-29T18:00:00"), - listOf("2025-05-18T16:00:00", "2025-05-18T18:00:00") - ) + generateDateRange("2024-11-29","2025-05-18","16:00:00","18:00:00") ), Triple( "마타하리", "LG아트센터 서울 SIGNATURE홀", - listOf( - listOf("2024-12-05T16:00:00", "2024-12-05T18:00:00"), - listOf("2025-03-02T16:00:00", "2025-03-02T18:00:00") - ) + generateDateRange("2024-12-05", "2025-03-02", "16:00:00","18:00:00") ), Triple( "웃는남자", "예술의전당 오페라극장", - listOf( - listOf("2025-01-09T16:00:00", "2025-01-09T18:00:00"), - listOf("2025-03-09T16:00:00", "2025-03-09T18:00:00") - ) + generateDateRange("2025-01-09", "2025-03-09", "16:00:00","18:00:00") ), Triple( "2025 기리보이 콘서트", "블루스퀘어 마스터카드홀", - listOf( - listOf("2025-02-01T16:00:00", "2025-02-01T18:00:00"), - listOf("2025-02-02T16:00:00", "2025-02-02T18:00:00") - ) + generateDateRange("2025-02-01", "2025-02-02", "16:00:00","18:00:00") ), Triple( "2025 검정치마 단독공연", "올림픽공원 올림픽홀", - listOf( - listOf("2025-02-01T16:00:00", "2025-02-01T18:00:00"), - listOf("2025-02-02T16:00:00", "2025-02-02T18:00:00") - ) + generateDateRange("2025-02-01", "2025-02-02", "16:00:00","18:00:00") ), Triple( "콜드플레이 내한공연", "고양종합운동장 주경기장", - listOf( - listOf("2025-04-16T16:00:00", "2025-04-16T18:00:00"), - listOf("2025-04-25T16:00:00", "2025-04-25T18:00:00") - ) + generateDateRange("2025-04-16", "2025-04-25", "16:00:00","18:00:00") ), Triple( "브루스 리우 피아노 리사이틀", "예술의전당 콘서트홀", - listOf( - listOf("2025-05-11T16:00:00", "2025-05-11T18:00:00") - ) + generateDateRange("2025-05-11", "2025-05-11", "16:00:00","18:00:00") ), Triple( "크리스티안 테츨라프 바이올린 리사이틀", "예술의전당 콘서트홀", - listOf( - listOf("2025-05-01T16:00:00", "2025-05-01T18:00:00") - ) + generateDateRange("2025-05-01", "2025-05-01", "16:00:00","18:00:00") ), Triple( "발레의 별빛, 글로벌 발레스타 초청 갈라공연", "세종문화회관 대극장", - listOf( - listOf("2025-01-11T16:00:00", "2025-01-11T18:00:00"), - listOf("2025-01-12T16:00:00", "2025-01-12T18:00:00") - ) + generateDateRange("2025-01-11", "2025-01-12", "16:00:00","18:00:00") ), Triple( "연극 애나엑스", "LG아트센터 서울 U+ 스테이지", - listOf( - listOf("2025-01-28T16:00:00", "2025-01-28T18:00:00"), - listOf("2025-03-16T16:00:00", "2025-03-16T18:00:00") - ) + generateDateRange("2025-01-28", "2025-03-16", "16:00:00","18:00:00") ), Triple( "연극 타인의 삶", "LG아트센터 서울 U+ 스테이지", - listOf( - listOf("2025-01-28T16:00:00", "2025-01-28T18:00:00"), - listOf("2025-03-16T16:00:00", "2025-03-16T18:00:00") - ) + generateDateRange("2025-01-28", "2025-03-16", "16:00:00","18:00:00") ), Triple( "세일즈맨의 죽음", "세종문화회관 M씨어터", - listOf( - listOf("2025-01-07T16:00:00", "2025-01-07T18:00:00"), - listOf("2025-03-03T16:00:00", "2025-03-03T18:00:00") - ) + generateDateRange("2025-01-07", "2025-03-03", "16:00:00","18:00:00") ) ) @@ -277,4 +246,31 @@ class DataInitializer( } } } + + fun generateDateRange( + startDate: String, endDate: String, + startTime: String, endTime: String + ): List> { + val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss") + val dateTimeFormatter = DateTimeFormatter.ISO_DATE_TIME + + val startLocalDate = LocalDate.parse(startDate, dateFormatter) + val endLocalDate = LocalDate.parse(endDate, dateFormatter) + val startLocalTime = LocalTime.parse(startTime, timeFormatter) + val endLocalTime = LocalTime.parse(endTime, timeFormatter) + + val dateList = mutableListOf>() + var currentDate = startLocalDate + + while (!currentDate.isAfter(endLocalDate)) { + val startDateTime = LocalDateTime.of(currentDate, startLocalTime) + val endDateTime = LocalDateTime.of(currentDate, endLocalTime) + dateList.add(listOf(startDateTime.format(dateTimeFormatter), endDateTime.format(dateTimeFormatter))) + + currentDate = currentDate.plusDays(1) // 하루 증가 + } + + return dateList + } } \ No newline at end of file diff --git a/src/test/resources/SeatApi.http b/src/test/resources/SeatApi.http index 34efbbc..6ddf00f 100644 --- a/src/test/resources/SeatApi.http +++ b/src/test/resources/SeatApi.http @@ -19,6 +19,9 @@ Content-Type: application/json "password": "12345678" } +### performance detail 조회 +GET http://localhost/api/v1/performance/26039e50-7bde-45ac-b845-0e75ae7548ac + ### performance 받기 GET http://localhost:80/api/v1/performance/search Accept: application/json From 223bbd349272096ea78d5f531fca72e1b99470e3 Mon Sep 17 00:00:00 2001 From: grantzile Date: Fri, 31 Jan 2025 19:49:13 +0900 Subject: [PATCH 125/162] CI: add env on CI --- .github/workflows/build-test.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index c9190f1..8ecba51 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -45,6 +45,11 @@ jobs: SPRING_DATASOURCE_PASSWORD: somepassword SPRING_JPA_HIBERNATE_DDL_AUTO: create-drop SPRING_PROFILES_ACTIVE: "prod" + KAKAO_CLIENT_ID: ${{ secrets.KAKAO_CLIENT_ID }} + KAKAO_CLIENT_SECRET: ${{ secrets.KAKAO_CLIENT_SECRET }} + NAVER_CLIENT_ID: ${{ secrets.NAVER_CLIENT_ID }} + NAVER_CLIENT_SECRET: ${{ secrets.NAVER_CLIENT_SECRET }} + JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} # 배포는 main 브랜치에서만 실행 - name: Upload JAR to EC2 From 997fb7e603a9db784667cf084f3c39ab7d444b85 Mon Sep 17 00:00:00 2001 From: grantzile Date: Fri, 31 Jan 2025 19:52:32 +0900 Subject: [PATCH 126/162] CI: add check on pull_requests --- .github/workflows/build-test.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index 8ecba51..642218f 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -5,6 +5,10 @@ on: branches: - main - '*' + pull_request: + branches: + - main + - release-dev jobs: deploy: From 305b232c90e734aabe633f4d8ce515e3a1c954ef Mon Sep 17 00:00:00 2001 From: ChungPlusPlus <153504213+ChungPlusPlus@users.noreply.github.com> Date: Fri, 31 Jan 2025 20:07:19 +0900 Subject: [PATCH 127/162] =?UTF-8?q?Pagination=20=EA=B5=AC=ED=98=84=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * merge test - generate performancehallentity.kt * feat: searchPerformance * chore: 도커 컴포즈 dockerfile 작성해서 도커 이미지 잘 만들어지고 http 파일로 테스트까지 가능한 상태입니다 * modify searchPerformance * add: docker-compose * modify build.gradle.kts * add wildcard import in UserService.kt * change Performance attribute 'genre' to 'category' * change category to ENUM Type * add some Init Datas * add some Init Datas * assign posterimageURL * add: Entity 엔티티 작업중 * assign posterimageURL * add: SeatService 임시저장하겠습니다 * feat: SeatService 좌석 확인, 예매, 예매 취소 기능 구현 아직 동시성 처리 안됐습니다 * feat: Add Controller 컨트롤러 추가했습니다 곧 테스트 코드 추가해보겠습니다 * modify searchPerformance, GET요청은 RequestBody를 사용할 수 없다고 하여 다시 RequestParam을 사용하는 원래 방식으로 변경 * feat: SeatCreation performanceHallId와 type을 받아 좌석엔티티들을 자동으로 생성하는 서비스 함수와 performanceEventId를 받아 빈 예약 가능 엔티티들을 자동을 생성하는 서비스를 만들었습니다 추후 createPerformanceHall과 createPerformanceEvent함수를 수정해서 과정을 더 자동화할 수 있을 것 같습니다 * add: SeatIntegrationTest SeatIntegrationTest 만들었습니다 테스트 전부 통과하며 빌드 성공합니다 다만 테스트에 mysql 데이터베이스가 쓰이며 초기화가 자동으로 되지 않아 문제가 생기니 이는 개선이 필요합니다 * chore: use h2 while test application.yaml 파일을 테스트 코드를 위해 하나 추가해줬습니다 테스트코드에서 @Transactional까지 사용한다면 테스트 코드가 서로 영향을 끼치지 않게 됩니다 * fix: Seat Reservation reservation 바뀐 정보 반영하도록 save 사용했고, Detail확인하는 영역은 컨트롤러까지 분리했습니다 * style: seat ktlint seat폴더의 파일들에 ktlint 적용 * modify MakeFile * 공연 검색 기능 구현 * fromEntity parameter change: receive Entities -> receive DTOs * modify searchPerformance endpoint URL * chore: makefile OS 따라서 다르게 작동하게 수정 * 공연 1개 상세 정보 반환 * minor modify: change how null type is handled * 초기 데이터 수정, 공연이벤트는 일단 공연 기간의 첫날과 마지막날만 생성하였음 * modify DataInitializer, SeatIntegrationTest * add PerformanceDetail Uri * make PerformanceItegrationTest.kt * feat: 예매 동시성 동시성 처리 완료 동시성 처리는 잘 되지만, 테스트 코드 작성이 많이 까다로움 * feat: Get My Reservation Api 유저가 로그인한채로 본인의 예약 목록을 확인할 수 있는 기능 추가 * feat: Seat Get TestCode Get API가 정상작동하는지 테스트코드 추가 * modify SearchPerformanceResponse * check auth for create, delete performance * check auth for create, delete performanceEvent, performanceHall * add posterUri attr to BriefPerformanceDetail * minor change * feat: 예매목록 Brief로 주기 기존에 id만 주던 것을 Brief 데이터의 리스트를 주는 것으로 수정 signin을 할 때 User도 같이 반환하도록 수정 * fix: 로그아웃, 토큰 재발급 버그 수정, 엔드포인트 수정 * add UserIdentityNotFoundException * modify PerformanceDurtion * feat: 공연장 생성시 좌석도 함께 생성 * feat: seat 초기화 적용, 버그 수정 PerformanceHall이 생성될 때 Seat을 같이 생성, PerformanceEvent가 생성될 때 Reservation을 같이 생성하도록 변경 * feat: Find PerformanceEvent PerformanceId와 LocalDate로부터 PerformanceEventId를 반환하도록 추가 잘못된 PerformanceEventId로 빈좌석정보를 확인했을 때 에러 반환 * chore: .env 무시 * feat: Reinforce Simultaneous test code 동시에 여러 사람의 접근이 있어도 통과하는지 테스트 코드 추가 동시에 여러 사람이 서로 다른 좌석에 접근할 때 테스트 코드 추가 * feat: Review/Reply 연결 review와 reply 마무리 * comment: user 설명 추가 username과 password의 조건 설명 추가 * chore: 필요없는 코드 지우기 * feat: Sort Reviews and Replies GET을 통해 리뷰나 댓글을 조회할 때 최신순으로 반환한다 * style: add new line at EOF * hotfix: test 안되던 오류 수정 * fix: Change Reservation ReservationEntity 예매하면서 만들어지도록 변경 * fix: error 핸들링 SeatService에서 DataIntegrityViolationException을 핸들링하기 위해 saveAndFlush를 사용했다. 기존에는 save가 트랜잭션이 끝나고 완료되어서 오류가 핸들링이 안 되었었다 * fix: Seat Test * fix: test conflict resolve 테스트에서 같은 username을 쓰던 부분을 수정 * feat: Seat Api Test * fix: 예매 취소 POST -> DELETE * add: Review, Reply DTO Instant -> LocalDatTime * feat: Cursor Pagination Tools CursorSpecification은 조건 추가해수는 기능 CursorPageService는 cursor 포함해서 검색하는 기능 CursorEncoder는 cursor를 컨트롤러에서 인코딩/디코딩 할 수 있도록 하는 기능 * feat: CursorPageable 적용 * fix: modify CursorPageable 디폴트 값을 데이터 클래스가 생성될 때 채워지는 것으로 적용 * fix: Reply Test Instant 대신 LocalDateTime을 씀에 따라 테스트에서의 파싱도 변경 * feat: response hasNext와 nextCursor 반환 * fix: nextCursor 수정 원래 객체 자체를 인코딩해서 ".id",".id"같은 값이 인코딩되어 나왔습니다 get을 사용해서 객체의 값을 가져올 수 있도록 수정했습니다 * feat: Performance Pagination 완성 * feat: pagination 적용된 review 조회 v2로 만들었습니다 * fix: Review Cursor 기능 완성 * feat: Reply에도 Cursor 적용 * fix: 엔드포인트 분리 * feat: performance 엔드포인트 분리 v1과 v2로 나누어 선택할 수 있도록 * feat: permitAll for pagination * fix: test 코드 수정 * fix: test 수정 * feat: Performance Search & Get Review 페이지네이션 적용 완료 * feat: Exception 처리 * fix: 기존 테스트 코드 유지 * feat: Reply Pagination 완성 Performance, Review, Reply Pagination 구현 완료 * feat: Performance Date 추가 --------- Co-authored-by: Dohyeon Kim --- .../interpark/config/DataInitializer.kt | 88 +++-- .../interpark/pagination/CursorEncoder.kt | 32 ++ .../interpark/pagination/CursorException.kt | 36 ++ .../interpark/pagination/CursorPageService.kt | 74 ++++ .../pagination/CursorSpecification.kt | 51 +++ .../controller/PerformanceController.kt | 17 + .../performance/service/PerformanceService.kt | 55 ++- .../interpark/review/controller/Reply.kt | 15 +- .../review/controller/ReplyController.kt | 13 + .../interpark/review/controller/Review.kt | 14 +- .../review/controller/ReviewController.kt | 13 + .../review/persistence/ReplyRepository.kt | 4 +- .../review/persistence/ReviewRepository.kt | 4 +- .../interpark/review/service/ReplyService.kt | 29 +- .../interpark/review/service/ReviewService.kt | 27 +- .../interpark/security/SecurityConfig.kt | 3 + .../wafflestudio/interpark/PaginationTest.kt | 322 ++++++++++++++++++ .../interpark/ReplyIntegrationTest.kt | 9 +- .../interpark/ReviewIntegrationTest.kt | 9 +- src/test/resources/GetApiTest.http | 76 +++++ src/test/resources/SeatApi.http | 3 + 21 files changed, 827 insertions(+), 67 deletions(-) create mode 100644 src/main/kotlin/com/wafflestudio/interpark/pagination/CursorEncoder.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/pagination/CursorSpecification.kt create mode 100644 src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt create mode 100644 src/test/resources/GetApiTest.http diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index 004f0e6..3db37db 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -10,7 +10,10 @@ import com.wafflestudio.interpark.performance.service.PerformanceHallService import com.wafflestudio.interpark.performance.service.PerformanceService import org.springframework.boot.CommandLineRunner import org.springframework.context.annotation.Configuration +import java.time.LocalDate import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter @Configuration class DataInitializer( @@ -164,96 +167,62 @@ class DataInitializer( Triple( "뮤지컬 지킬앤하이드", "블루스퀘어 신한카드홀", - listOf( - listOf("2024-11-29T16:00:00", "2024-11-29T18:00:00"), - listOf("2025-05-18T16:00:00", "2025-05-18T18:00:00") - ) + generateDateRange("2024-11-29","2025-05-18","16:00:00","18:00:00") ), Triple( "마타하리", "LG아트센터 서울 SIGNATURE홀", - listOf( - listOf("2024-12-05T16:00:00", "2024-12-05T18:00:00"), - listOf("2025-03-02T16:00:00", "2025-03-02T18:00:00") - ) + generateDateRange("2024-12-05", "2025-03-02", "16:00:00","18:00:00") ), Triple( "웃는남자", "예술의전당 오페라극장", - listOf( - listOf("2025-01-09T16:00:00", "2025-01-09T18:00:00"), - listOf("2025-03-09T16:00:00", "2025-03-09T18:00:00") - ) + generateDateRange("2025-01-09", "2025-03-09", "16:00:00","18:00:00") ), Triple( "2025 기리보이 콘서트", "블루스퀘어 마스터카드홀", - listOf( - listOf("2025-02-01T16:00:00", "2025-02-01T18:00:00"), - listOf("2025-02-02T16:00:00", "2025-02-02T18:00:00") - ) + generateDateRange("2025-02-01", "2025-02-02", "16:00:00","18:00:00") ), Triple( "2025 검정치마 단독공연", "올림픽공원 올림픽홀", - listOf( - listOf("2025-02-01T16:00:00", "2025-02-01T18:00:00"), - listOf("2025-02-02T16:00:00", "2025-02-02T18:00:00") - ) + generateDateRange("2025-02-01", "2025-02-02", "16:00:00","18:00:00") ), Triple( "콜드플레이 내한공연", "고양종합운동장 주경기장", - listOf( - listOf("2025-04-16T16:00:00", "2025-04-16T18:00:00"), - listOf("2025-04-25T16:00:00", "2025-04-25T18:00:00") - ) + generateDateRange("2025-04-16", "2025-04-25", "16:00:00","18:00:00") ), Triple( "브루스 리우 피아노 리사이틀", "예술의전당 콘서트홀", - listOf( - listOf("2025-05-11T16:00:00", "2025-05-11T18:00:00") - ) + generateDateRange("2025-05-11", "2025-05-11", "16:00:00","18:00:00") ), Triple( "크리스티안 테츨라프 바이올린 리사이틀", "예술의전당 콘서트홀", - listOf( - listOf("2025-05-01T16:00:00", "2025-05-01T18:00:00") - ) + generateDateRange("2025-05-01", "2025-05-01", "16:00:00","18:00:00") ), Triple( "발레의 별빛, 글로벌 발레스타 초청 갈라공연", "세종문화회관 대극장", - listOf( - listOf("2025-01-11T16:00:00", "2025-01-11T18:00:00"), - listOf("2025-01-12T16:00:00", "2025-01-12T18:00:00") - ) + generateDateRange("2025-01-11", "2025-01-12", "16:00:00","18:00:00") ), Triple( "연극 애나엑스", "LG아트센터 서울 U+ 스테이지", - listOf( - listOf("2025-01-28T16:00:00", "2025-01-28T18:00:00"), - listOf("2025-03-16T16:00:00", "2025-03-16T18:00:00") - ) + generateDateRange("2025-01-28", "2025-03-16", "16:00:00","18:00:00") ), Triple( "연극 타인의 삶", "LG아트센터 서울 U+ 스테이지", - listOf( - listOf("2025-01-28T16:00:00", "2025-01-28T18:00:00"), - listOf("2025-03-16T16:00:00", "2025-03-16T18:00:00") - ) + generateDateRange("2025-01-28", "2025-03-16", "16:00:00","18:00:00") ), Triple( "세일즈맨의 죽음", "세종문화회관 M씨어터", - listOf( - listOf("2025-01-07T16:00:00", "2025-01-07T18:00:00"), - listOf("2025-03-03T16:00:00", "2025-03-03T18:00:00") - ) + generateDateRange("2025-01-07", "2025-03-03", "16:00:00","18:00:00") ) ) @@ -277,4 +246,31 @@ class DataInitializer( } } } + + fun generateDateRange( + startDate: String, endDate: String, + startTime: String, endTime: String + ): List> { + val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss") + val dateTimeFormatter = DateTimeFormatter.ISO_DATE_TIME + + val startLocalDate = LocalDate.parse(startDate, dateFormatter) + val endLocalDate = LocalDate.parse(endDate, dateFormatter) + val startLocalTime = LocalTime.parse(startTime, timeFormatter) + val endLocalTime = LocalTime.parse(endTime, timeFormatter) + + val dateList = mutableListOf>() + var currentDate = startLocalDate + + while (!currentDate.isAfter(endLocalDate)) { + val startDateTime = LocalDateTime.of(currentDate, startLocalTime) + val endDateTime = LocalDateTime.of(currentDate, endLocalTime) + dateList.add(listOf(startDateTime.format(dateTimeFormatter), endDateTime.format(dateTimeFormatter))) + + currentDate = currentDate.plusDays(1) // 하루 증가 + } + + return dateList + } } \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorEncoder.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorEncoder.kt new file mode 100644 index 0000000..2401bd3 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorEncoder.kt @@ -0,0 +1,32 @@ +package com.wafflestudio.interpark.pagination + +import java.util.Base64 +import kotlin.text.Charsets.UTF_8 + +object CursorEncoder { + fun encodeCursor(targetEntity: Any, fieldName: String): String { + val idCursor = targetEntity.javaClass.getDeclaredField("id").apply { isAccessible = true }.get(targetEntity) + val fieldCursor = targetEntity.javaClass.getDeclaredField(fieldName).apply { isAccessible = true }.get(targetEntity) + + val cursorString = "$fieldCursor,$idCursor" + return Base64.getEncoder().encodeToString(cursorString.toByteArray(UTF_8)) + } + + fun decodeCursor(encodedCursor: String): Pair? { + return try { + val decodedString = String(Base64.getDecoder().decode(encodedCursor), UTF_8) + val parts = decodedString.split(",") + if( parts.size == 2 ) { + val fieldCursor = parts[0] + val idCursor = parts[1] + + fieldCursor to idCursor + } + else { + null + } + } catch (e: Exception) { + null + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt new file mode 100644 index 0000000..ccda7e1 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt @@ -0,0 +1,36 @@ +package com.wafflestudio.interpark.pagination + +import com.wafflestudio.interpark.DomainException +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatusCode + +sealed class CursorException( + errorCode: Int, + httpStatusCode: HttpStatusCode, + msg: String, + cause: Throwable? = null, +) : DomainException(errorCode, httpStatusCode, msg, cause) + +class InvalidCursorException : CursorException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "Failed to decode Cursor", +) + +class InvalidFieldNameException : CursorException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "Invalid field name", +) + +class FieldNotFoundException : CursorException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "Field not found", +) + +class CursorNotComparableException : CursorException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "Cursor not comparable", +) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt new file mode 100644 index 0000000..e4064a1 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorPageService.kt @@ -0,0 +1,74 @@ +package com.wafflestudio.interpark.pagination + +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.data.jpa.domain.Specification +import org.springframework.data.jpa.repository.JpaSpecificationExecutor +import java.awt.Cursor +import java.time.Instant + +abstract class CursorPageService( + private val repository: JpaSpecificationExecutor +) { + fun findAllWithCursor( + cursorPageable: CursorPageable, + specification: Specification? = null, + ): CursorPageResponse { + val cursor = cursorPageable.decodeCursor() + + val parsedCursor = cursor?.let { + val parsedFieldCursor = when(cursorPageable.sortFieldName) { + "createdAt" -> cursor.let { Instant.parse(cursor.first) } + "id" -> cursor.first + else -> throw InvalidFieldNameException() + } + parsedFieldCursor to cursor.second + } + + val cursorSpec = CursorSpecification.withCursor( + cursor = parsedCursor, + sortFieldName = cursorPageable.sortFieldName, + isDescending = cursorPageable.isDescending, + ) + + val combinedSpec = if(specification != null) { + Specification.where(cursorSpec).and(specification) + } else { + cursorSpec + } + + val sortDirection = if(cursorPageable.isDescending) Sort.Direction.DESC else Sort.Direction.ASC + val pageable = PageRequest.of(0, cursorPageable.size+1, Sort.by(sortDirection, cursorPageable.sortFieldName, "id")) + + val results = repository.findAll(combinedSpec, pageable).content + val hasNext = results.size > cursorPageable.size + + val returnData = if(hasNext) results.dropLast(1) else results + val nextCursor = returnData.lastOrNull()?.let { + CursorEncoder.encodeCursor(it, cursorPageable.sortFieldName) + } + + return CursorPageResponse( + data = returnData, + nextCursor = nextCursor, + hasNext = hasNext, + ) + } +} + +data class CursorPageable( + val cursor: String?, + val sortFieldName: String = "id", // 기준이 없다면 id만 가지고 정렬 + val isDescending: Boolean = true, + val size: Int = 5, +) { + fun decodeCursor(): Pair? { + return cursor?.let { CursorEncoder.decodeCursor(it) ?: throw InvalidCursorException() } + } +} + +data class CursorPageResponse( + val data: List, + val nextCursor: String?, + val hasNext : Boolean, +) \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorSpecification.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorSpecification.kt new file mode 100644 index 0000000..656260e --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorSpecification.kt @@ -0,0 +1,51 @@ +package com.wafflestudio.interpark.pagination + +import org.springframework.data.jpa.domain.Specification + +// 정렬기준이 되는 field를 기준으로 조건을 설정 +object CursorSpecification { + fun withCursor( + cursor: Pair?, + sortFieldName: String, + isDescending: Boolean = true, + ): Specification? { + if (cursor == null) return null + val fieldCursor = cursor.first as? Comparable + ?: throw CursorNotComparableException() + val idCursor = cursor.second as? String + ?: throw CursorNotComparableException() + + return Specification {root, _, cb -> + val fieldPath = try { + root.get>(sortFieldName) + } catch (e: IllegalArgumentException) { + throw FieldNotFoundException() + } + val idPath = try { + root.get("id") + } catch (e: IllegalArgumentException) { + throw FieldNotFoundException() + } + + if(isDescending) { + cb.or( + cb.lessThan(fieldPath, fieldCursor), + cb.and( + cb.equal(fieldPath, fieldCursor), + cb.lessThan(idPath, idCursor) + ) + ) + } + else { + cb.or( + cb.greaterThan(fieldPath, fieldCursor), + cb.and( + cb.equal(fieldPath, fieldCursor), + cb.greaterThan(idPath, idCursor) + ) + ) + } + + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index f5867d2..3f72899 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -1,5 +1,7 @@ package com.wafflestudio.interpark.performance.controller +import com.wafflestudio.interpark.pagination.CursorPageResponse +import com.wafflestudio.interpark.pagination.CursorPageable import com.wafflestudio.interpark.performance.persistence.PerformanceCategory import io.swagger.v3.oas.annotations.Operation import com.wafflestudio.interpark.performance.service.PerformanceService @@ -29,6 +31,19 @@ class PerformanceController( return ResponseEntity.ok(queriedPerformances) } + @GetMapping("/api/v2/performance/search") + fun searchCursorPerformance( + @RequestParam title: String?, + @RequestParam category: PerformanceCategory?, + @RequestParam cursor: String?, + ): ResponseEntity { + // @RequestParam(defaultValue) 대신 데이터 클래스 내부적으로 기본값 처리 + val cursorPageable= CursorPageable(cursor = cursor) + + val queriedPerformances = performanceService.searchPerformanceWithCursor(title, category, cursorPageable) + return ResponseEntity.ok(queriedPerformances) + } + // WARN: THIS IS FOR ADMIN. // TODO: SEPERATE THIS TO OTHER APPLICATION @PostMapping("/admin/v1/performance") @@ -73,6 +88,8 @@ class PerformanceController( typealias SearchPerformanceResponse = List +typealias SearchCursorPerformanceResponse = CursorPageResponse + data class BriefPerformanceDetail( val id: String, val title: String, diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt index 029ad82..c46e066 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/service/PerformanceService.kt @@ -1,5 +1,8 @@ package com.wafflestudio.interpark.performance.service +import com.wafflestudio.interpark.pagination.CursorPageResponse +import com.wafflestudio.interpark.pagination.CursorPageService +import com.wafflestudio.interpark.pagination.CursorPageable import com.wafflestudio.interpark.performance.PerformanceNotFoundException import com.wafflestudio.interpark.performance.controller.BriefPerformanceDetail import com.wafflestudio.interpark.performance.controller.Performance @@ -9,12 +12,13 @@ import com.wafflestudio.interpark.performance.persistence.* import org.springframework.data.jpa.domain.Specification import org.springframework.stereotype.Service import org.springframework.data.repository.findByIdOrNull +import java.awt.Cursor @Service class PerformanceService( private val performanceRepository: PerformanceRepository, private val performanceEventRepository: PerformanceEventRepository, -) { +) : CursorPageService(performanceRepository) { fun searchPerformance( title: String?, category: PerformanceCategory?, @@ -55,6 +59,55 @@ class PerformanceService( } } + fun searchPerformanceWithCursor( + title: String?, + category: PerformanceCategory?, + cursorPageable: CursorPageable, + ): CursorPageResponse { + // 시작점: 아무 조건이 없는 스펙 + var spec: Specification = Specification.where(null) + + // title 조건이 있다면 스펙에 and로 연결 + PerformanceSpecifications.withTitle(title)?.let { + spec = spec.and(it) + } + + // category 조건이 있다면 스펙에 and로 연결 + PerformanceSpecifications.withCategory(category)?.let { + spec = spec.and(it) + } + + // 스펙이 결국 아무 조건도 없으면 -> 전체 검색 + val searchResult = findAllWithCursor(cursorPageable, spec) + + val performanceEntities = searchResult.data + + // BriefDetail DTO 변환 + val performanceData = performanceEntities.map { performanceEntity -> + val performanceEventEntities = performanceEventRepository.findAllByPerformanceId(performanceEntity.id!!) + val performanceEvents = if (performanceEventEntities.isEmpty()) { + null + } else { + performanceEventEntities.map { PerformanceEvent.fromEntity(it) } + } + val performanceHall = performanceEventEntities.firstOrNull()?.let { + PerformanceHall.fromEntity(it.performanceHall) + } + + Performance.fromEntityToBriefDetails( + performanceEntity = performanceEntity, + performanceHall = performanceHall, + performanceEvents = performanceEvents + ) + } + + return CursorPageResponse( + data = performanceData, + nextCursor = searchResult.nextCursor, + hasNext = searchResult.hasNext, + ) + } + fun getAllPerformance(): List { return performanceRepository .findAll() diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt index b533d39..846e267 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Reply.kt @@ -2,13 +2,16 @@ package com.wafflestudio.interpark.review.controller import com.wafflestudio.interpark.review.persistence.ReplyEntity import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId data class Reply( val id: String, val author: String, val content: String, - val createdAt: Instant, - val updatedAt: Instant, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, ) { companion object { fun fromEntity(entity: ReplyEntity): Reply { @@ -16,9 +19,13 @@ data class Reply( id = entity.id!!, author = entity.author.nickname, content = entity.content, - createdAt = entity.createdAt, - updatedAt = entity.updatedAt, + createdAt = convertInstantToKoreanTime(entity.createdAt), + updatedAt = convertInstantToKoreanTime(entity.updatedAt), ) } + + private fun convertInstantToKoreanTime(instant: Instant): LocalDateTime { + return LocalDateTime.ofInstant(instant, ZoneId.of("Asia/Seoul")) + } } } \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt index 5fac5ff..87d0151 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt @@ -1,5 +1,7 @@ package com.wafflestudio.interpark.review.controller +import com.wafflestudio.interpark.pagination.CursorPageResponse +import com.wafflestudio.interpark.pagination.CursorPageable import com.wafflestudio.interpark.review.service.ReplyService import com.wafflestudio.interpark.user.controller.UserDetailsImpl import org.springframework.http.ResponseEntity @@ -26,6 +28,16 @@ class ReplyController( return ResponseEntity.ok(replies) } + @GetMapping("/api/v2/review/{reviewId}/reply") + fun getCursorReplies( + @PathVariable reviewId: String, + @RequestParam cursor: String?, + ): ResponseEntity{ + val cursorPageable= CursorPageable(sortFieldName = "createdAt", cursor = cursor) + val reviews = replyService.getRepliesWithCursor(reviewId, cursorPageable) + return ResponseEntity.ok(reviews) + } + @PostMapping("/api/v1/review/{reviewId}/reply") fun createReply( @RequestBody request: CreateReplyRequest, @@ -60,6 +72,7 @@ class ReplyController( typealias GetReplyResponse = List +typealias GetCursorReplyResponse = CursorPageResponse data class CreateReplyRequest( val content: String, diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt index 3f91d49..d9e42b2 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/Review.kt @@ -2,6 +2,8 @@ package com.wafflestudio.interpark.review.controller import com.wafflestudio.interpark.review.persistence.ReviewEntity import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId data class Review( val id: String, @@ -9,8 +11,8 @@ data class Review( val rating: Int, val title: String, val content: String, - val createdAt: Instant, - val updatedAt: Instant, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, val likeCount: Int, val replyCount: Int, ) { @@ -22,11 +24,15 @@ data class Review( rating = entity.rating, title = entity.title, content = entity.content, - createdAt = entity.createdAt, - updatedAt = entity.updatedAt, + createdAt = convertInstantToKoreanTime(entity.createdAt), + updatedAt = convertInstantToKoreanTime(entity.updatedAt), likeCount = entity.reviewLikes.size, replyCount = replyCount, ) } + + private fun convertInstantToKoreanTime(instant: Instant): LocalDateTime { + return LocalDateTime.ofInstant(instant, ZoneId.of("Asia/Seoul")) + } } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt index bfb1d00..42a112c 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt @@ -1,5 +1,7 @@ package com.wafflestudio.interpark.review.controller +import com.wafflestudio.interpark.pagination.CursorPageResponse +import com.wafflestudio.interpark.pagination.CursorPageable import com.wafflestudio.interpark.review.* import com.wafflestudio.interpark.review.service.ReplyService import com.wafflestudio.interpark.review.service.ReviewService @@ -29,6 +31,16 @@ class ReviewController( return ResponseEntity.ok(reviews) } + @GetMapping("/api/v2/performance/{performanceId}/review") + fun getCursorReviews( + @PathVariable performanceId: String, + @RequestParam cursor: String?, + ): ResponseEntity{ + val cursorPageable= CursorPageable(sortFieldName = "createdAt", cursor = cursor) + val reviews = reviewService.getReviewsWithCursor(performanceId, cursorPageable) + return ResponseEntity.ok(reviews) + } + @PostMapping("/api/v1/performance/{performanceId}/review") fun createReview( @RequestBody request: CreateReviewRequest, @@ -80,6 +92,7 @@ class ReviewController( typealias GetReviewResponse = List +typealias GetCursorReviewResponse = CursorPageResponse data class CreateReviewRequest( val rating: Int, diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt index befd9a5..d49d831 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyRepository.kt @@ -1,9 +1,11 @@ package com.wafflestudio.interpark.review.persistence import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.JpaSpecificationExecutor import org.springframework.data.jpa.repository.Query -interface ReplyRepository : JpaRepository { +interface ReplyRepository : JpaRepository, + JpaSpecificationExecutor { @Query("SELECT r FROM ReplyEntity r WHERE r.review.id = :reviewId ORDER BY r.createdAt DESC") fun findByReviewId(reviewId: String): List @Query("SELECT r FROM ReplyEntity r WHERE r.author.id = :authorId ORDER BY r.createdAt DESC") diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt index 52720c0..ddd66be 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewRepository.kt @@ -2,10 +2,12 @@ package com.wafflestudio.interpark.review.persistence import jakarta.persistence.LockModeType import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.JpaSpecificationExecutor import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Query -interface ReviewRepository : JpaRepository { +interface ReviewRepository : JpaRepository, + JpaSpecificationExecutor { @Query("SELECT r FROM ReviewEntity r WHERE r.performance.id = :performanceId ORDER BY r.createdAt DESC") fun findByPerformanceId(performanceId: String): List @Query("SELECT r FROM ReviewEntity r WHERE r.author.id = :authorId ORDER BY r.createdAt DESC") diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt index 3db7ebf..6711b11 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReplyService.kt @@ -1,14 +1,21 @@ package com.wafflestudio.interpark.review.service +import com.wafflestudio.interpark.pagination.CursorPageResponse +import com.wafflestudio.interpark.pagination.CursorPageService +import com.wafflestudio.interpark.pagination.CursorPageable +import com.wafflestudio.interpark.performance.persistence.PerformanceEntity import com.wafflestudio.interpark.review.* import com.wafflestudio.interpark.review.persistence.ReviewRepository import com.wafflestudio.interpark.review.controller.Reply +import com.wafflestudio.interpark.review.controller.Review import com.wafflestudio.interpark.review.persistence.ReplyEntity import com.wafflestudio.interpark.review.persistence.ReplyRepository +import com.wafflestudio.interpark.review.persistence.ReviewEntity import com.wafflestudio.interpark.user.AuthenticateException import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.persistence.UserRepository import jakarta.persistence.EntityManager +import org.springframework.data.jpa.domain.Specification import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -20,7 +27,7 @@ class ReplyService( private val reviewRepository: ReviewRepository, private val replyRepository: ReplyRepository, private val userRepository: UserRepository, -) { +) : CursorPageService(replyRepository) { fun getRepliesByUser(userId: String): List { val replies: List = @@ -38,6 +45,26 @@ class ReplyService( return replies } + fun getRepliesWithCursor( + reviewId: String, + cursorPageable: CursorPageable, + ): CursorPageResponse { + val spec: Specification = Specification.where { root, _, cb -> + cb.equal(root.get("review").get("id"), reviewId) + } + + val searchResult = findAllWithCursor(cursorPageable, spec) + val replyEntities = searchResult.data + + val replyData = replyEntities.map { Reply.fromEntity(it) } + + return CursorPageResponse( + data = replyData, + nextCursor = searchResult.nextCursor, + hasNext = searchResult.hasNext, + ) + } + fun countReplies(reviewId: String): Int { val replyCount = replyRepository.countByReviewId(reviewId) return replyCount diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt index 2307fad..a6c5f80 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/service/ReviewService.kt @@ -1,6 +1,10 @@ package com.wafflestudio.interpark.review.service +import com.wafflestudio.interpark.pagination.CursorPageResponse +import com.wafflestudio.interpark.pagination.CursorPageService +import com.wafflestudio.interpark.pagination.CursorPageable import com.wafflestudio.interpark.performance.PerformanceNotFoundException +import com.wafflestudio.interpark.performance.persistence.PerformanceEntity import com.wafflestudio.interpark.performance.persistence.PerformanceRepository import com.wafflestudio.interpark.review.* import com.wafflestudio.interpark.review.controller.Review @@ -12,6 +16,7 @@ import com.wafflestudio.interpark.user.AuthenticateException import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.persistence.UserRepository import jakarta.persistence.EntityManager +import org.springframework.data.jpa.domain.Specification import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -24,7 +29,7 @@ class ReviewService( private val userRepository: UserRepository, private val reviewLikeRepository: ReviewLikeRepository, private val replyService: ReplyService, -) { +) : CursorPageService(reviewRepository) { fun getReviewsByUser(userId: String): List { val reviews: List = reviewRepository @@ -41,6 +46,26 @@ class ReviewService( return reviews } + fun getReviewsWithCursor( + performanceId: String, + cursorPageable: CursorPageable, + ): CursorPageResponse { + val spec: Specification = Specification.where { root, _, cb -> + cb.equal(root.get("performance").get("id"), performanceId) + } + + val searchResult = findAllWithCursor(cursorPageable, spec) + val reviewEntities = searchResult.data + + val reviewData = reviewEntities.map { Review.fromEntity(it, replyService.countReplies(it.id)) } + + return CursorPageResponse( + data = reviewData, + nextCursor = searchResult.nextCursor, + hasNext = searchResult.hasNext, + ) + } + @Transactional fun createReview( authorId: String, diff --git a/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt index dd4aa4f..759c05a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt @@ -26,6 +26,7 @@ class SecurityConfig ( authorizeHttpRequests { // 사용자 권한 authorize(HttpMethod.GET, "/api/v1/performance/search", permitAll) // 공연 조회 + authorize(HttpMethod.GET, "/api/v2/performance/search", permitAll) // 공연 조회 + 페이지네이션 authorize(HttpMethod.GET, "/api/v1/performance/{performanceId}", permitAll) // 공연 상세정보 반환 authorize(HttpMethod.GET, "/api/v1/performance-event", permitAll) authorize(HttpMethod.GET, "/api/v1/performance-event/{performanceId}/{performanceDate}", permitAll) @@ -37,7 +38,9 @@ class SecurityConfig ( authorize(HttpMethod.POST, "/api/v1/auth/refresh_token", permitAll) authorize(HttpMethod.GET, "/api/v1/seat/{performanceEventId}/available", permitAll) authorize(HttpMethod.GET, "/api/v1/performance/{performanceId}/review", permitAll) + authorize(HttpMethod.GET, "/api/v2/performance/{performanceId}/review", permitAll) authorize(HttpMethod.GET, "/api/v1/review/{reviewId}/reply", permitAll) + authorize(HttpMethod.GET, "/api/v2/review/{reviewId}/reply", permitAll) authorize("/api/v1/**", hasAnyRole("USER", "ADMIN")) // 그 외 모두 유저 권한 필요 authorize("/admin/v1/**", hasRole("ADMIN")) diff --git a/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt new file mode 100644 index 0000000..cb41ce1 --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt @@ -0,0 +1,322 @@ +package com.wafflestudio.interpark + +import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.interpark.performance.persistence.PerformanceCategory +import com.wafflestudio.interpark.user.persistence.UserRole +import org.hamcrest.Matchers +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Transactional +class PaginationTest +@Autowired +constructor( + private val mvc: MockMvc, + private val mapper: ObjectMapper, +) { + private lateinit var userAccessToken: String + private lateinit var performanceId: String + + @BeforeEach + fun setUp() { + val username = UUID.randomUUID().toString().take(8) + val password = "password123" + + // 1️⃣ 회원가입 + // 일반 유저 + mvc.perform( + post("/api/v1/local/signup") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + "nickname" to "test_user", + "phoneNumber" to "010-0000-0000", + "email" to "test@example.com", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + + // 2️⃣ 로그인 → 토큰 획득 + // 일반 유저 + userAccessToken = + mvc.perform( + post("/api/v1/local/signin") + .content( + mapper.writeValueAsString( + mapOf( + "username" to username, + "password" to password, + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("accessToken").asText() } + + // 3️⃣ 테스트용 공연 ID 반환 + performanceId = + mvc.perform( + get("/api/v2/performance/search") + .header("Authorization", "Bearer $userAccessToken") + .param("title", "지킬앤하이드") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + val node = mapper.readTree(it) + val firstItem = node.get("data").firstOrNull() ?: error("Response array is empty") + val idNode = firstItem.get("id") + requireNotNull(idNode) { "ID not found in response item: $firstItem" } + idNode.asText() + } + } + + @Test + fun `공연 전체 조회 페이지네이션 테스트`() { + var cursor: String? = null + val maxIteration = 4 + var iterations = 0 + var totalItems = 0 + + while(iterations < maxIteration) { + val response = mvc.perform( + get("/api/v2/performance/search") + .apply { cursor?.let { param("cursor", it) } } + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$.data.size()").value(Matchers.greaterThan(0))) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + + val hasNext = response.get("hasNext").asBoolean() + val dataSize = response.get("data").size() + totalItems += dataSize + if(!hasNext) { + break + } + cursor = response.get("nextCursor").asText() + + iterations++ + } + + // 전체 데이터를 다 가져왔는지 확인 + val totalSize = mvc.perform( + get("/api/v1/performance/search") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).size() } + + assert( totalItems == totalSize ) {"Expected $totalSize items but got $totalItems"} + } + + @Test + fun `공연 일부 조회 페이지네이션 테스트`() { + var cursor: String? = null + val maxIteration = 4 + var iterations = 0 + var totalItems = 0 + + while(iterations < maxIteration) { + val response = mvc.perform( + get("/api/v2/performance/search") + .param("category", PerformanceCategory.CONCERT.name) + .apply { cursor?.let { param("cursor", it) } } + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + + val hasNext = response.get("hasNext").asBoolean() + val dataSize = response.get("data").size() + totalItems += dataSize + if(!hasNext) { + break + } + cursor = response.get("nextCursor").asText() + + iterations++ + } + + // 전체 데이터를 다 가져왔는지 확인 + val totalSize = mvc.perform( + get("/api/v1/performance/search") + .param("category", PerformanceCategory.CONCERT.name) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).size() } + + assert( totalItems == totalSize ) {"Expected $totalSize items but got $totalItems"} + } + + @Test + fun `잘못된 커서로 요청하면 오류`() { + mvc.perform( + get("/api/v2/performance/search") + .apply { param("cursor", "WrongCursor") } + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(400)) + } + + @Test + fun `공연의 리뷰 조회 페이지네이션 테스트`() { + val reviewId1 = + mvc.perform( + post("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $userAccessToken") + .content( + mapper.writeValueAsString( + mapOf( + "rating" to 5, + "title" to "Great Performance!", + "content" to "Absolutely amazing. Highly recommend!", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("id").asText() } + + (1..5).forEach { + mvc.perform( + post("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $userAccessToken") + .content( + mapper.writeValueAsString( + mapOf( + "rating" to it, + "title" to "Great Performance! $it", + "content" to "Absolutely amazing. Highly recommend!", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + } + + val response = mvc.perform( + get("/api/v2/performance/$performanceId/review") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + + val hasNext = response.get("hasNext").asBoolean() + assert(hasNext) {"expected hasNext true but false"} + + val cursor = response.get("nextCursor").asText() + // 가장 먼저 등록한 리뷰가 가장 마지막에 조회된다 + mvc.perform( + get("/api/v2/performance/$performanceId/review") + .apply { param("cursor", cursor) } + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$.data[?(@.id == '$reviewId1')]").exists()) + .andExpect(jsonPath("$.hasNext").value(false)) + } + + @Test + fun `리뷰의 댓글 조회 페이지네이션 테스트`() { + val reviewId = + mvc.perform( + post("/api/v1/performance/$performanceId/review") + .header("Authorization", "Bearer $userAccessToken") + .content( + mapper.writeValueAsString( + mapOf( + "rating" to 5, + "title" to "Great Performance!", + "content" to "Absolutely amazing. Highly recommend!", + ), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("id").asText() } + + val replyId1 = + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $userAccessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "First Reply"), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it).get("id").asText() } + + (1..5).forEach { + mvc.perform( + post("/api/v1/review/$reviewId/reply") + .header("Authorization", "Bearer $userAccessToken") + .content( + mapper.writeValueAsString( + mapOf("content" to "$it"), + ), + ) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().`is`(201)) + .andReturn() + } + + val response = mvc.perform( + get("/api/v2/review/$reviewId/reply") + ).andExpect(status().`is`(200)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readTree(it) } + + val hasNext = response.get("hasNext").asBoolean() + assert(hasNext) {"expected hasNext true but false"} + + val cursor = response.get("nextCursor").asText() + // 가장 먼저 등록한 댓글이 가장 마지막에 조회된다 + mvc.perform( + get("/api/v2/review/$reviewId/reply") + .apply { param("cursor", cursor) } + ).andExpect(status().`is`(200)) + .andExpect(jsonPath("$.data[?(@.id == '$replyId1')]").exists()) + .andExpect(jsonPath("$.hasNext").value(false)) + } +} diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt index a5096eb..c8fe103 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReplyIntegrationTest.kt @@ -12,6 +12,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* import org.springframework.transaction.annotation.Transactional import java.time.Instant +import java.time.LocalDateTime import java.util.UUID @AutoConfigureMockMvc @@ -459,8 +460,8 @@ class ReplyIntegrationTest .response .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it) } - .map { Instant.parse(it.get("createdAt").asText()) } - val isReviewReplySorted = reviewReplies.zipWithNext { a,b -> !a.isBefore(b) }.all {it} + .map { LocalDateTime.parse(it.get("createdAt").asText()) } + val isReviewReplySorted = reviewReplies.zipWithNext { a,b -> a>=b }.all {it} assert (isReviewReplySorted) { "expected review rating sorted but not" } val userReplies = mvc.perform( @@ -471,8 +472,8 @@ class ReplyIntegrationTest .response .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it) } - .map { Instant.parse(it.get("createdAt").asText()) } - val isUserReplySorted = userReplies.zipWithNext { a,b -> !a.isBefore(b) }.all {it} + .map { LocalDateTime.parse(it.get("createdAt").asText()) } + val isUserReplySorted = userReplies.zipWithNext { a,b -> a >=b }.all {it} assert (isUserReplySorted) { "expected user reply sorted but not" } } } diff --git a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt index fd3f6c6..6367a7a 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/ReviewIntegrationTest.kt @@ -13,6 +13,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultHandlers.* import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* import org.springframework.transaction.annotation.Transactional import java.time.Instant +import java.time.LocalDateTime import java.util.UUID @AutoConfigureMockMvc @@ -332,8 +333,8 @@ class ReviewIntegrationTest .response .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it) } - .map { Instant.parse(it.get("createdAt").asText()) } - val isPerformanceReviewSorted = performanceReviews.zipWithNext { a,b -> !a.isBefore(b) }.all {it} + .map { LocalDateTime.parse(it.get("createdAt").asText()) } + val isPerformanceReviewSorted = performanceReviews.zipWithNext { a,b -> a>=b }.all {it} assert (isPerformanceReviewSorted) { "expected sorted performance reviews but not" } val userReviews = mvc.perform( @@ -344,8 +345,8 @@ class ReviewIntegrationTest .response .getContentAsString(Charsets.UTF_8) .let { mapper.readTree(it) } - .map { Instant.parse(it.get("createdAt").asText()) } - val isUserReviewSorted = userReviews.zipWithNext { a,b -> !a.isBefore(b) }.all {it} + .map { LocalDateTime.parse(it.get("createdAt").asText()) } + val isUserReviewSorted = userReviews.zipWithNext { a,b -> a>=b }.all {it} assert (isUserReviewSorted) { "expected sorted user reviews but not" } } } diff --git a/src/test/resources/GetApiTest.http b/src/test/resources/GetApiTest.http new file mode 100644 index 0000000..5aad715 --- /dev/null +++ b/src/test/resources/GetApiTest.http @@ -0,0 +1,76 @@ +### performance 받기 +GET http://localhost/api/v2/performance/search +Accept: application/json + +### 다음 cursor로 요청 +GET http://localhost/api/v2/performance/search?cursor=OGE1YzU0MjctY2IzOC00OTMzLWFkZGYtOWU2NTQxOTkwNGU0LDhhNWM1NDI3LWNiMzgtNDkzMy1hZGRmLTllNjU0MTk5MDRlNA== +Accept: application/json + +### 다음 cursor로 요청 +GET http://localhost/api/v2/performance/search?cursor=MjRkMWQwZGItNzIxYi00OWE1LWE1MmMtMjc2ZDBmODg1N2M3LDI0ZDFkMGRiLTcyMWItNDlhNS1hNTJjLTI3NmQwZjg4NTdjNw== +Accept: application/json + +### 다음 cursor로 요청 +GET http://localhost/api/v2/performance/search?cursor=MDcwYjkwN2EtZGM1ZS00ZmU0LTgzNDEtZDIyYWY1ZWQ3MmNiLDA3MGI5MDdhLWRjNWUtNGZlNC04MzQxLWQyMmFmNWVkNzJjYg== +Accept: application/json + +### 회원가입(USER) +POST http://localhost:80/api/v1/local/signup +Content-Type: application/json + +{ + "username": "correct", + "password": "12345678", + "nickname": "examplename", + "phoneNumber": "010-0000-0000", + "email": "test@example.com" +} + +### 로그인 +POST http://localhost:80/api/v1/local/signin +Content-Type: application/json + +{ + "username": "correct", + "password": "12345678" +} + +### 리뷰 쓰기 +POST http://localhost/api/v1/performance/ec18db5d-09ab-47d0-8fec-46858a2780a7/review +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkMTQwYTU0Ni03ZDdiLTQ5OGItOWM4MS0zMTczYmQwODEzZjgiLCJpYXQiOjE3MzgzMTExNDUsImV4cCI6MTczODMxMjA0NX0.eR5Qh-B4h73reHCt82YWgpd8I1rhtdW8UIUajM1SGOE +Content-Type: application/json + +{ + "rating": 3, + "title": "1st Bad Good", + "content": "very good" +} + +### 리뷰 조회 +GET http://localhost/api/v2/performance/d2f2bf55-be48-4981-b92a-b2c6aa181826/review +Content-Type: application/json + +### 커서와 함께 리뷰 조회 +GET http://localhost/api/v2/performance/d2f2bf55-be48-4981-b92a-b2c6aa181826/review?cursor=MjAyNS0wMS0zMFQwODowNzo0Ny4wOTE5NTVaLDg4YTgyZDA2LTdlZDQtNGM0NC05ZmJjLWUwYzZjNzlkNjE3YQ== +Content-Type: application/json + +### 커서와 함께 한번더 리뷰 조회 +GET http://localhost/api/v2/performance/f8d31155-8243-48a8-a060-3306b52fa227/review?cursor=MjAyNS0wMS0zMFQwODowNzo0MC44NTQzMzhaLDgzODI0Njg4LWIzMGEtNGZmZi1iOThjLTgzNmRlOTNhMjMxYg== +Content-Type: application/json + +### 댓글 쓰기 +POST http://localhost/api/v1/review/8dc698c8-26c2-4bbe-b548-33b9b0584d3a/reply +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkMTQwYTU0Ni03ZDdiLTQ5OGItOWM4MS0zMTczYmQwODEzZjgiLCJpYXQiOjE3MzgzMTExNDUsImV4cCI6MTczODMxMjA0NX0.eR5Qh-B4h73reHCt82YWgpd8I1rhtdW8UIUajM1SGOE +Content-Type: application/json + +{ + "content": "Agree7" +} + +### 댓글 조회 +GET http://localhost/api/v2/review/8dc698c8-26c2-4bbe-b548-33b9b0584d3a/reply +Content-Type: application/json + +### 커서와 함께 댓글 조회 +GET http://localhost/api/v2/review/8dc698c8-26c2-4bbe-b548-33b9b0584d3a/reply?cursor=MjAyNS0wMS0zMVQwODoxNToxMS41MzM0MDFaLDg2ZjFmNmRiLWMwNWEtNGIwOS04OWQwLWM3OWNlMDA1YTg4YQ== +Content-Type: application/json diff --git a/src/test/resources/SeatApi.http b/src/test/resources/SeatApi.http index 34efbbc..6ddf00f 100644 --- a/src/test/resources/SeatApi.http +++ b/src/test/resources/SeatApi.http @@ -19,6 +19,9 @@ Content-Type: application/json "password": "12345678" } +### performance detail 조회 +GET http://localhost/api/v1/performance/26039e50-7bde-45ac-b845-0e75ae7548ac + ### performance 받기 GET http://localhost:80/api/v1/performance/search Accept: application/json From 1c560d5d86c87c3ed134f404bd2770ca6e2773e2 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Fri, 31 Jan 2025 20:23:21 +0900 Subject: [PATCH 128/162] rollback userservice.signup() --- .../interpark/user/service/UserService.kt | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt index 1c807d0..8c89f46 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt @@ -20,7 +20,6 @@ class UserService( private val userRepository: UserRepository, private val userIdentityRepository: UserIdentityRepository, private val userAccessTokenUtil: UserAccessTokenUtil, - private val socialAccountRepository: SocialAccountRepository, ) { @Transactional fun signUp( @@ -30,8 +29,6 @@ class UserService( phoneNumber: String, email: String, role: UserRole = UserRole.USER, - provider: Provider? = null, - providerId: String? = null, ): User { if (username.length < 6 || username.length > 20) { throw SignUpBadUsernameException() @@ -61,17 +58,6 @@ class UserService( ), ) - // 소셜 계정 연동 - if (provider != null && providerId != null) { - socialAccountRepository.save( - SocialAccountEntity( - userIdentity = userIdentity, - provider = provider, - providerId = providerId, - ), - ) - } - return User.fromEntity(user) } From dcbb27a9a597f7b7c7a5ee4f8f90dfd6295b363e Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Fri, 31 Jan 2025 20:39:19 +0900 Subject: [PATCH 129/162] rollback signup --- .../wafflestudio/interpark/user/controller/UserController.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt index a327be9..65e31e2 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt @@ -98,7 +98,6 @@ class UserController( email = request.email, phoneNumber = request.phoneNumber, role = request.role, - provider = request.provider, ) return ResponseEntity.ok(SignUpResponse(user)) } @@ -180,7 +179,6 @@ data class SignUpRequest( val phoneNumber: String, val email: String, val role: UserRole = UserRole.USER, - val provider: Provider? = null, ) data class SignUpResponse(val user: User) From e5c5c03550ee3d4910ba999998ccf23dd2544755 Mon Sep 17 00:00:00 2001 From: DoHyeon Kim Date: Fri, 31 Jan 2025 21:51:28 +0900 Subject: [PATCH 130/162] =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * merge test - generate performancehallentity.kt * feat: searchPerformance * chore: 도커 컴포즈 dockerfile 작성해서 도커 이미지 잘 만들어지고 http 파일로 테스트까지 가능한 상태입니다 * modify searchPerformance * add: docker-compose * modify build.gradle.kts * add wildcard import in UserService.kt * change Performance attribute 'genre' to 'category' * change category to ENUM Type * add some Init Datas * add some Init Datas * assign posterimageURL * add: Entity 엔티티 작업중 * assign posterimageURL * add: SeatService 임시저장하겠습니다 * feat: SeatService 좌석 확인, 예매, 예매 취소 기능 구현 아직 동시성 처리 안됐습니다 * feat: Add Controller 컨트롤러 추가했습니다 곧 테스트 코드 추가해보겠습니다 * modify searchPerformance, GET요청은 RequestBody를 사용할 수 없다고 하여 다시 RequestParam을 사용하는 원래 방식으로 변경 * feat: SeatCreation performanceHallId와 type을 받아 좌석엔티티들을 자동으로 생성하는 서비스 함수와 performanceEventId를 받아 빈 예약 가능 엔티티들을 자동을 생성하는 서비스를 만들었습니다 추후 createPerformanceHall과 createPerformanceEvent함수를 수정해서 과정을 더 자동화할 수 있을 것 같습니다 * add: SeatIntegrationTest SeatIntegrationTest 만들었습니다 테스트 전부 통과하며 빌드 성공합니다 다만 테스트에 mysql 데이터베이스가 쓰이며 초기화가 자동으로 되지 않아 문제가 생기니 이는 개선이 필요합니다 * chore: use h2 while test application.yaml 파일을 테스트 코드를 위해 하나 추가해줬습니다 테스트코드에서 @Transactional까지 사용한다면 테스트 코드가 서로 영향을 끼치지 않게 됩니다 * fix: Seat Reservation reservation 바뀐 정보 반영하도록 save 사용했고, Detail확인하는 영역은 컨트롤러까지 분리했습니다 * style: seat ktlint seat폴더의 파일들에 ktlint 적용 * modify MakeFile * 공연 검색 기능 구현 * fromEntity parameter change: receive Entities -> receive DTOs * modify searchPerformance endpoint URL * chore: makefile OS 따라서 다르게 작동하게 수정 * 공연 1개 상세 정보 반환 * minor modify: change how null type is handled * 초기 데이터 수정, 공연이벤트는 일단 공연 기간의 첫날과 마지막날만 생성하였음 * modify DataInitializer, SeatIntegrationTest * add PerformanceDetail Uri * make PerformanceItegrationTest.kt * feat: 예매 동시성 동시성 처리 완료 동시성 처리는 잘 되지만, 테스트 코드 작성이 많이 까다로움 * feat: Get My Reservation Api 유저가 로그인한채로 본인의 예약 목록을 확인할 수 있는 기능 추가 * feat: Seat Get TestCode Get API가 정상작동하는지 테스트코드 추가 * modify SearchPerformanceResponse * check auth for create, delete performance * check auth for create, delete performanceEvent, performanceHall * add posterUri attr to BriefPerformanceDetail * minor change * feat: 예매목록 Brief로 주기 기존에 id만 주던 것을 Brief 데이터의 리스트를 주는 것으로 수정 signin을 할 때 User도 같이 반환하도록 수정 * fix: 로그아웃, 토큰 재발급 버그 수정, 엔드포인트 수정 * add UserIdentityNotFoundException * modify PerformanceDurtion * backup package to * modify endpoint * applying spring security * setup .env * fix .http localhost port number * apply spring security * apply spring security * minor * remove .env from tracked files * gitignore * remove unused method * minor * minor * add new line at EOF * modify jwtfilter comment * convert Instant to KoreanLocalDateTime in PerformanceEventDTO * add permission * throw AuthenticateException when accessToken is not valid * save * roll back 401 -> 403 when access denied * setup .env * social login 구현중 중간 save * add SocialLoginApi.http * remove .env * write swagger api for socialauthcontroller * modify social login api docs description * add social login query paramter explanations * social endpoint permitall * social login receive socialAccessToken, not authorizatoinCode * modify social login explanation * rollback userservice.signup() * rollback signup --------- Co-authored-by: ChungPlusPlus --- build.gradle.kts | 3 + .../interpark/GlobalExceptionHandler.kt | 13 +- .../interpark/config/JwtConfig.kt | 19 + .../interpark/config/SwaggerConfig.kt | 38 ++ .../security/JwtAuthenticationFilter.kt | 2 - .../interpark/security/SecurityConfig.kt | 1 + .../interpark/user/UserAccessTokenUtil.kt | 11 +- .../interpark/user/UserException.kt | 16 + .../user/controller/SocialAuthController.kt | 190 ++++++++++ .../user/controller/UserController.kt | 1 + .../user/controller/UserDetailsImpl.kt | 2 - .../user/persistence/SocialAccountEntity.kt | 36 ++ .../persistence/SocialAccountRepository.kt | 7 + .../user/persistence/UserIdentityEntity.kt | 9 +- .../user/service/SocialAuthService.kt | 334 ++++++++++++++++++ .../interpark/user/service/UserService.kt | 20 +- src/main/resources/application.yaml | 31 ++ src/test/resources/SocialLoginApi.http | 19 + src/test/resources/application.yaml | 33 +- 19 files changed, 764 insertions(+), 21 deletions(-) create mode 100644 src/main/kotlin/com/wafflestudio/interpark/config/JwtConfig.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/user/persistence/SocialAccountEntity.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/user/persistence/SocialAccountRepository.kt create mode 100644 src/main/kotlin/com/wafflestudio/interpark/user/service/SocialAuthService.kt create mode 100644 src/test/resources/SocialLoginApi.http diff --git a/build.gradle.kts b/build.gradle.kts index 7afc0ba..9548812 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,7 +22,10 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-oauth2-client") + implementation("org.springframework.boot:spring-boot-starter-webflux") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("io.github.cdimascio:dotenv-kotlin:6.4.1") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.mindrot:jbcrypt:0.4") implementation("com.mysql:mysql-connector-j:8.2.0") diff --git a/src/main/kotlin/com/wafflestudio/interpark/GlobalExceptionHandler.kt b/src/main/kotlin/com/wafflestudio/interpark/GlobalExceptionHandler.kt index 73195c1..7a5c20e 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/GlobalExceptionHandler.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/GlobalExceptionHandler.kt @@ -1,5 +1,6 @@ package com.wafflestudio.interpark +import com.wafflestudio.interpark.user.SocialAccountNotFoundException import org.springframework.http.ResponseEntity import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ControllerAdvice @@ -9,9 +10,19 @@ import org.springframework.web.bind.annotation.ExceptionHandler class GlobalExceptionHandler { @ExceptionHandler(DomainException::class) fun handle(exception: DomainException): ResponseEntity> { + val responseBody = mutableMapOf( + "error" to exception.msg, + "errorCode" to exception.errorCode + ) + + if (exception is SocialAccountNotFoundException) { + responseBody["provider"] = exception.provider.toString() + responseBody["providerId"] = exception.providerId + } + return ResponseEntity .status(exception.httpErrorCode) - .body(mapOf("error" to exception.msg, "errorCode" to exception.errorCode)) + .body(responseBody) } @ExceptionHandler(MethodArgumentNotValidException::class) diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/JwtConfig.kt b/src/main/kotlin/com/wafflestudio/interpark/config/JwtConfig.kt new file mode 100644 index 0000000..fe0f3e9 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/config/JwtConfig.kt @@ -0,0 +1,19 @@ +package com.wafflestudio.interpark.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import io.jsonwebtoken.security.Keys +import java.nio.charset.StandardCharsets +import javax.crypto.SecretKey + +@Configuration +class JwtConfig { + @Value("\${jwt.secret}") + private lateinit var secretKey: String + + @Bean + fun secretKeySpec(): SecretKey { + return Keys.hmacShaKeyFor(secretKey.toByteArray(StandardCharsets.UTF_8)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/SwaggerConfig.kt b/src/main/kotlin/com/wafflestudio/interpark/config/SwaggerConfig.kt index 9813b0a..19398d1 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/SwaggerConfig.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/SwaggerConfig.kt @@ -31,6 +31,8 @@ class SwaggerConfig { ) .addSecurityItem(SecurityRequirement().addList("Bearer Authentication")) .addSecurityItem(SecurityRequirement().addList("Google OAuth2")) + .addSecurityItem(SecurityRequirement().addList("Kakao OAuth2")) + .addSecurityItem(SecurityRequirement().addList("Naver OAuth2")) .components( Components() .addSecuritySchemes( @@ -57,6 +59,42 @@ class SwaggerConfig { ) ) ) + .addSecuritySchemes( + "Kakao OAuth2", + SecurityScheme() + .type(SecurityScheme.Type.OAUTH2) + .flows( + OAuthFlows() + .authorizationCode( + OAuthFlow() + .authorizationUrl("https://kauth.kakao.com/oauth/authorize") + .tokenUrl("https://kauth.kakao.com/oauth/token") + .scopes( + Scopes() + .addString("account_email", "email access") + .addString("profile", "profile access") + ) + ) + ) + ) + .addSecuritySchemes( + "Naver OAuth2", + SecurityScheme() + .type(SecurityScheme.Type.OAUTH2) + .flows( + OAuthFlows() + .authorizationCode( + OAuthFlow() + .authorizationUrl("https://nid.naver.com/oauth2.0/authorize") + .tokenUrl("https://nid.naver.com/oauth2.0/token") + .scopes( + Scopes() + .addString("email", "email access") + .addString("name", "name access") + ) + ) + ) + ) ) } } \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt index 6f53488..8bb7b6b 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/security/JwtAuthenticationFilter.kt @@ -1,11 +1,9 @@ package com.wafflestudio.interpark.security import com.wafflestudio.interpark.user.UserAccessTokenUtil -import com.wafflestudio.interpark.user.controller.UserDetailsImpl import com.wafflestudio.interpark.user.service.UserDetailsServiceImpl import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.web.filter.OncePerRequestFilter import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest diff --git a/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt index 759c05a..fca29b8 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/security/SecurityConfig.kt @@ -41,6 +41,7 @@ class SecurityConfig ( authorize(HttpMethod.GET, "/api/v2/performance/{performanceId}/review", permitAll) authorize(HttpMethod.GET, "/api/v1/review/{reviewId}/reply", permitAll) authorize(HttpMethod.GET, "/api/v2/review/{reviewId}/reply", permitAll) + authorize(HttpMethod.POST, "/api/v1/social/**", permitAll) authorize("/api/v1/**", hasAnyRole("USER", "ADMIN")) // 그 외 모두 유저 권한 필요 authorize("/admin/v1/**", hasRole("ADMIN")) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/UserAccessTokenUtil.kt b/src/main/kotlin/com/wafflestudio/interpark/user/UserAccessTokenUtil.kt index 7804127..adc8282 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/UserAccessTokenUtil.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/UserAccessTokenUtil.kt @@ -4,19 +4,22 @@ import com.wafflestudio.interpark.user.persistence.RefreshTokenEntity import com.wafflestudio.interpark.user.persistence.RefreshTokenRepository import io.jsonwebtoken.Jwts import io.jsonwebtoken.security.Keys +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import java.nio.charset.StandardCharsets import java.util.* +import javax.crypto.SecretKey @Component class UserAccessTokenUtil( private var refreshTokenRepository: RefreshTokenRepository, + private val secretKey: SecretKey ) { fun generateAccessToken(username: String): String { val now = Date() val expiryDate = Date(now.time + ACCESS_EXPIRATION_TIME) return Jwts.builder() - .signWith(SECRET_KEY) + .signWith(secretKey) .setSubject(username) .setIssuedAt(now) .setExpiration(expiryDate) @@ -27,7 +30,7 @@ class UserAccessTokenUtil( return try { val claims = Jwts.parserBuilder() - .setSigningKey(SECRET_KEY) + .setSigningKey(secretKey) .build() .parseClaimsJws(accessToken) .body @@ -80,7 +83,9 @@ class UserAccessTokenUtil( companion object { private const val ACCESS_EXPIRATION_TIME = 1000 * 60 * 15 // 15 minutes private const val REFRESH_EXPIRATION_TIME = 1000 * 60 * 60 * 24 // 1 day - private val SECRET_KEY = Keys.hmacShaKeyFor("THISSHOULDBEPROTECTEDASDFASDFASDFASDFASDFASDF".toByteArray(StandardCharsets.UTF_8)) +// @Value("\${jwt.secret}") +// lateinit var secretKey: String +// private val SECRET_KEY = Keys.hmacShaKeyFor(secretKey.toByteArray(StandardCharsets.UTF_8)) // TODO("비밀키 숨겨야 한다") } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt b/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt index 21c19fe..a5153cc 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt @@ -1,6 +1,7 @@ package com.wafflestudio.interpark.user import com.wafflestudio.interpark.DomainException +import com.wafflestudio.interpark.user.persistence.Provider import org.springframework.http.HttpStatus import org.springframework.http.HttpStatusCode @@ -64,3 +65,18 @@ class NoRefreshTokenException : UserException( httpStatusCode = HttpStatus.UNAUTHORIZED, msg = "Token not found", ) + +class SocialAccountAlreadyLinkedException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.CONFLICT, + msg = "Social Account already linked to another user", +) + +class SocialAccountNotFoundException ( + val provider: Provider, + val providerId: String, +): UserException( + errorCode = 0, + httpStatusCode = HttpStatus.NOT_FOUND, + msg = "This $provider account is not linked to local account", +) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt new file mode 100644 index 0000000..9ceb650 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/SocialAuthController.kt @@ -0,0 +1,190 @@ +package com.wafflestudio.interpark.user.controller + +import com.wafflestudio.interpark.user.persistence.Provider +import com.wafflestudio.interpark.user.service.SocialAuthService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import jakarta.servlet.http.Cookie +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +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.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/social") +class SocialAuthController( + val socialAuthService: SocialAuthService +) { + @Operation( + summary = "소셜 로그인 요청", + description = """ + 클라이언트가 소셜 서버 엑세스 토큰과 provider(ex. KAKAO, NAVER)를 요청에 담아 소셜로그인을 요청합니다. + 서버에서는 엑세스 토큰을 소셜 계정 정보와 교환합니다. + + - 로그인한 소셜 계정이 로컬 계정과 연동되어 있는 계정인 경우 + 기존 로컬 로그인 응답 객체에 provider와 providerId를 추가로 담아 반환합니다. + 이 때, providerId는 사용자 고유 식별자입니다. + + - 로그인한 소셜 계정이 로컬 계정과 연동되어 있지 않은 경우 + 404에러와 본문에 provider, providerId를 담아 반환합니다. + 이 값을 이용해서 추후에 "/api/v1/social/link"로 연동 요청을 보내시면 됩니다. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "소셜 로그인 성공", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = SocialLoginResponse::class) + )] + ), + ApiResponse( + responseCode = "404", + description = "소셜 계정이 로컬 계정과 연동되어 있지 않음", + content = [Content( + mediaType = "application/json", + schema = Schema( + type = "object", + example = """ + { + "error": "This KAKAO account is not linked to local account", + "errorCode": 0, + "provider": "KAKAO", + "providerId": "1234567890" + } + """ + ) + )] + ), + ], + ) + @PostMapping("/{provider}/login") + fun socialLogin( + @Parameter(description = "소셜 로그인 제공자 (예: KAKAO, NAVER)", example = "KAKAO") + @PathVariable provider: Provider, + + @Parameter(description = "소셜 인증 서버 액세스 토큰") + @RequestParam("token") socialAccessToken: String, + + response: HttpServletResponse + ): ResponseEntity { + val result = socialAuthService.socialLogin(provider, socialAccessToken) + val (user, accessToken, refreshToken, providerId) = result + val cookie = + Cookie("refreshToken", refreshToken).apply { + isHttpOnly = true + secure = true + path = "/api/v1/auth" + maxAge = 60 * 60 * 24 * 7 + // TODO("domain 설정하기") + } + response.addCookie(cookie) + + return ResponseEntity.ok(SocialLoginResponse(user, accessToken, provider, providerId)) + } + + @Operation( + summary = "소셜 계정 - 로컬 계정 연동 요청", + description = """ + username, password, provider, providerId를 요청본문에 담아 로컬계정 연동을 요청합니다. + 소셜 계정 유저가 로컬 계정 유저인지 확인하기 위해 username과 password를 통해 인증 절차를 통과해야만 + 연동이 완료됩니다. + """, + responses = [ + ApiResponse( + responseCode = "200", + description = "로컬 계정과의 연동 성공", + content = [] + ), + ApiResponse( + responseCode = "404", + description = "username에 해당하는 로컬 계정이 존재하지 않음", + content = [Content( + mediaType = "application/json", + schema = Schema( + type = "object", + example = """ + { + "error": "UserIdentity not found", + "errorCode": 0 + } + """ + ) + )] + ), + ApiResponse( + responseCode = "401", + description = "비밀번호가 유효하지 앟음", + content = [Content( + mediaType = "application/json", + schema = Schema( + type = "object", + example = """ + { + "error": "Invalid Password", + "errorCode": 0 + } + """ + ) + )] + ), + ApiResponse( + responseCode = "409", + description = "이미 연동된 계정에 대해 다시 연동 요청을 보내고 있음", + content = [Content( + mediaType = "application/json", + schema = Schema( + type = "object", + example = """ + { + "error": "Social Account already linked to another user", + "errorCode": 0 + } + """ + ) + )] + ), + ], + ) + @PostMapping("/link") + fun linkSocialAccount( + @RequestBody request: LinkSocialAccountRequest + ): ResponseEntity { + socialAuthService.linkSocialAccount( + username = request.username, + password = request.password, + provider = request.provider, + providerId = request.providerId + ) + return ResponseEntity.ok().build() + } + + @GetMapping("/callback") + fun callback( + @RequestParam code: String, + ) : ResponseEntity> { + return ResponseEntity.ok(mapOf("code" to code)) + } +} + +data class SocialLoginResponse( + val user: User, + val accessToken: String, + val provider: Provider, + val providerId: String +) + +data class LinkSocialAccountRequest( + val username: String, + val password: String, + val provider: Provider, + val providerId: String, +) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt index 771aa6e..65e31e2 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt @@ -1,6 +1,7 @@ package com.wafflestudio.interpark.user.controller import com.wafflestudio.interpark.user.* +import com.wafflestudio.interpark.user.persistence.Provider import com.wafflestudio.interpark.user.persistence.UserRole import com.wafflestudio.interpark.user.service.UserService import io.swagger.v3.oas.annotations.Operation diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt index 66265e8..2208621 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserDetailsImpl.kt @@ -1,9 +1,7 @@ package com.wafflestudio.interpark.user.controller -import com.wafflestudio.interpark.user.persistence.UserEntity import com.wafflestudio.interpark.user.persistence.UserIdentityEntity import org.springframework.security.core.GrantedAuthority -import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.userdetails.UserDetails class UserDetailsImpl ( diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/SocialAccountEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/SocialAccountEntity.kt new file mode 100644 index 0000000..478877a --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/SocialAccountEntity.kt @@ -0,0 +1,36 @@ +package com.wafflestudio.interpark.user.persistence + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.Id +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne + +@Entity +class SocialAccountEntity ( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_identity_id") + val userIdentity: UserIdentityEntity, + @Column(name = "provider", nullable = false) + val provider: Provider, + @Column(name = "provider_id", nullable = false) + val providerId: String, +) + +enum class Provider(val displayName: String) { + GOOGLE("Google"), + KAKAO("Kakao"), + NAVER("Naver"); + + companion object { + fun fromName(name: String): Provider? { + return entries.find { it.name == name.uppercase() } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/SocialAccountRepository.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/SocialAccountRepository.kt new file mode 100644 index 0000000..fe82d62 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/SocialAccountRepository.kt @@ -0,0 +1,7 @@ +package com.wafflestudio.interpark.user.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface SocialAccountRepository : JpaRepository { + fun findByProviderAndProviderId(provider: Provider, providerId: String): SocialAccountEntity? +} \ No newline at end of file diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt index 81cd197..5421ad4 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt @@ -1,12 +1,15 @@ package com.wafflestudio.interpark.user.persistence +import jakarta.persistence.CascadeType import jakarta.persistence.Column import jakarta.persistence.Entity +import jakarta.persistence.FetchType import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.OneToOne +import jakarta.persistence.OneToMany import org.springframework.security.core.GrantedAuthority @Entity @@ -21,10 +24,8 @@ class UserIdentityEntity( var role: UserRole = UserRole.USER, @Column(name = "hashed_password", nullable = false) val hashedPassword: String, - @Column(name = "provider", nullable = false) - val provider: String, - @Column(name = "social_id", nullable = true) - val socialId: String? = null, +// @OneToMany(mappedBy = "userIdentity", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) +// val socialAccounts: MutableList = mutableListOf(), ) enum class UserRole : GrantedAuthority { diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/SocialAuthService.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/SocialAuthService.kt new file mode 100644 index 0000000..95d7f4d --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/SocialAuthService.kt @@ -0,0 +1,334 @@ +package com.wafflestudio.interpark.user.service + +import com.wafflestudio.interpark.user.SignInInvalidPasswordException +import com.wafflestudio.interpark.user.SocialAccountAlreadyLinkedException +import com.wafflestudio.interpark.user.SocialAccountNotFoundException +import com.wafflestudio.interpark.user.UserAccessTokenUtil +import com.wafflestudio.interpark.user.UserIdentityNotFoundException +import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.persistence.Provider +import com.wafflestudio.interpark.user.persistence.SocialAccountEntity +import com.wafflestudio.interpark.user.persistence.SocialAccountRepository +import com.wafflestudio.interpark.user.persistence.UserIdentityEntity +import com.wafflestudio.interpark.user.persistence.UserIdentityRepository +import org.mindrot.jbcrypt.BCrypt +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.reactive.function.client.WebClient + +@Service +class SocialAuthService( + private val socialAccountRepository: SocialAccountRepository, + private val userIdentityRepository: UserIdentityRepository, + private val userAccessTokenUtil: UserAccessTokenUtil, + // 카카오 설정 + @Value("\${spring.security.oauth2.client.provider.kakao.token-uri}") + private val kakaoTokenUri: String, + @Value("\${spring.security.oauth2.client.provider.kakao.user-info-uri}") + private val kakaoUserInfoUri: String, + @Value("\${spring.security.oauth2.client.registration.kakao.client-id}") + private val kakaoClientId: String, + @Value("\${spring.security.oauth2.client.registration.kakao.client-secret:}") + private val kakaoClientSecret: String?, + + // 네이버 설정 + @Value("\${spring.security.oauth2.client.provider.naver.token-uri}") + private val naverTokenUri: String, + @Value("\${spring.security.oauth2.client.provider.naver.user-info-uri}") + private val naverUserInfoUri: String, + @Value("\${spring.security.oauth2.client.registration.naver.client-id}") + private val naverClientId: String, + @Value("\${spring.security.oauth2.client.registration.naver.client-secret}") + private val naverClientSecret: String +) { + @Transactional + fun linkSocialAccount( + username: String, + password: String, + provider: Provider, + providerId: String + ): UserIdentityEntity { + // linkSocialAccount는 회원가입 완료된 로컬 계정에 한해 호출된다고 전제 + // 유저 확인 + val userIdentity = userIdentityRepository.findByUserUsername(username) ?: throw UserIdentityNotFoundException() + // 로컬 계정 패스워드 확인 + if (!BCrypt.checkpw(password, userIdentity.hashedPassword)) { + throw SignInInvalidPasswordException() + } + + // 소셜 계정 중복 확인 + val existingSocialAccount = socialAccountRepository.findByProviderAndProviderId(provider, providerId) + if (existingSocialAccount != null) { + if (existingSocialAccount.userIdentity.user.username != username) { + throw SocialAccountAlreadyLinkedException() + } + } + + // 소셜 계정 생성 및 연동 + val socialAccount = SocialAccountEntity( + userIdentity = userIdentity, + provider = provider, + providerId = providerId + ) + socialAccountRepository.save(socialAccount) + + return userIdentity + } + + fun exchangeCodeForToken( + provider: Provider, + code: String + ): SocialTokenResponse { + return when (provider) { + Provider.KAKAO -> exchangeKakaoToken(code) + Provider.NAVER -> exchangeNaverToken(code) + else -> throw IllegalArgumentException("Unsupported provider: $provider") + } + } + + private fun exchangeKakaoToken( + code: String + ): SocialTokenResponse { + val bodyMap = LinkedMultiValueMap().apply { + this["grant_type"] = "authorization_code" + this["client_id"] = kakaoClientId + if (!kakaoClientSecret.isNullOrEmpty()) { + this["client_secret"] = kakaoClientSecret + } + this["code"] = code + } + // 필요하면 redirect_uri도 추가 + + // (1) webClient 인스턴스 생성 (혹은 주입) + val client = WebClient.builder() + .baseUrl(kakaoTokenUri) + .build() + + // (2) POST 요청 + val response = client.post() + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .bodyValue(bodyMap) + .retrieve() // status 4xx, 5xx 시 오류 발생하게 함 + .bodyToMono(KakaoTokenResponse::class.java) // 비동기 Mono + .block() // 여기서는 동기 블록으로 처리(데모 용) + + // (3) 응답 파싱 + val tokenBody = response ?: throw RuntimeException("Kakao token response is null") + + return SocialTokenResponse( + accessToken = tokenBody.accessToken ?: "", + refreshToken = tokenBody.refreshToken, + tokenType = tokenBody.tokenType, + expiresIn = tokenBody.expiresIn ?: 0 + ) + } + + private fun exchangeNaverToken(code: String): SocialTokenResponse { + val bodyMap = LinkedMultiValueMap().apply { + this["grant_type"] = "authorization_code" + this["client_id"] = naverClientId + this["client_secret"] = naverClientSecret + this["code"] = code + } + + val client = WebClient.builder() + .baseUrl(naverTokenUri) + .build() + + // 1) POST 요청 -> NaverTokenResponse + val tokenResponse = client.post() + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .bodyValue(bodyMap) + .retrieve() + .bodyToMono(NaverTokenResponse::class.java) + .block() // 동기식 블록 (데모용) + + // 2) 응답 처리 및 예외 처리 + requireNotNull(tokenResponse) { "Naver token response is null" } + require(tokenResponse.error == null) { + "Naver token error: ${tokenResponse.error}, desc = ${tokenResponse.errorDescription}" + } + + // 3) 결과를 공통 DTO(SocialTokenResponse)로 변환 + return SocialTokenResponse( + accessToken = tokenResponse.accessToken.orEmpty(), + refreshToken = tokenResponse.refreshToken, + tokenType = tokenResponse.tokenType, + expiresIn = tokenResponse.expiresIn ?: 0 + ) + } + + fun getUserInfo(provider: Provider, accessToken: String): SocialUserInfo { + return when (provider) { + Provider.KAKAO -> getKakaoUserInfo(accessToken) + Provider.NAVER -> getNaverUserInfo(accessToken) + else -> throw IllegalArgumentException("Unsupported provider: $provider") + } + } + + private fun getKakaoUserInfo(accessToken: String): SocialUserInfo { + val client = WebClient.builder() + .baseUrl(kakaoUserInfoUri) // https://kapi.kakao.com/v2/user/me + .build() + + val response = client.get() + .header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + .retrieve() + .bodyToMono(KakaoUserInfoResponse::class.java) + .block() + + val body = response ?: throw RuntimeException("Kakao userinfo is null") + val id = body.id?.toString() ?: throw RuntimeException("Kakao user id missing") + val email = body.kakaoAccount?.email + val nickname = body.kakaoAccount?.profile?.nickname + + return SocialUserInfo( + provider = Provider.KAKAO, + providerId = id, + email = email, + nickname = nickname + ) + } + + private fun getNaverUserInfo(accessToken: String): SocialUserInfo { + val client = WebClient.builder() + .baseUrl(naverUserInfoUri) // 예: "https://openapi.naver.com/v1/nid/me" + .build() + + // 1) GET 요청, Authorization 헤더에 Bearer 토큰 + val response = client.get() + .header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + .retrieve() + .bodyToMono(NaverUserInfoResponse::class.java) + .block() + + // 2) 응답이 null이거나 에러인지 체크 + if (response == null) { + throw RuntimeException("Naver user info response is null") + } + if (response.resultcode != "00") { + throw RuntimeException("Naver userinfo error: ${response.message}") + } + + val detail = response.response + ?: throw RuntimeException("Naver userinfo 'response' field is null") + + // 3) 필요한 식별 정보(id, email, nickname 등) 추출 + val id = detail.id + ?: throw RuntimeException("Naver user id is null") + val email = detail.email + val nickname = detail.nickname + + // 4) 공통 DTO (SocialUserInfo)로 변환 + return SocialUserInfo( + provider = Provider.NAVER, + providerId = id, + email = email, + nickname = nickname + ) + } + + @Transactional + fun socialLogin( + provider: Provider, + accessToken: String, + ) : SocialLoginResult { + // (1) code -> token + //val token = exchangeCodeForToken(provider, code) + + // (2) token -> userInfo + val userInfo = getUserInfo(provider, accessToken) + + // (3) DB에서 "이미 존재하는" 소셜 계정 찾기 + val socialAccount = socialAccountRepository.findByProviderAndProviderId(provider, userInfo.providerId) + ?: throw SocialAccountNotFoundException(provider, userInfo.providerId) + + val userEntity = socialAccount.userIdentity.user + val user = User.fromEntity(userEntity) + + // (4) 로그인 성공 -> JWT 발행 + return SocialLoginResult( + user = user, + accessToken = userAccessTokenUtil.generateAccessToken(userEntity.id!!), + refreshToken = userAccessTokenUtil.generateRefreshToken(userEntity.id!!), + providerId = userInfo.providerId + ) + } +} + +data class SocialTokenResponse( + val accessToken: String, + val refreshToken: String? = null, + val tokenType: String? = null, + val expiresIn: Int? = null +) + +data class SocialUserInfo( + val provider: Provider, + val providerId: String, + val email: String?, + val nickname: String? + // 소셜 계정의 email과 nickname을 현재는 따로 사용하지 않음 +) + +data class SocialLoginResult( + val user: User, + val accessToken: String, + val refreshToken: String, + val providerId: String + // 필요하면 JWT, refreshToken, etc... +) + +// 카카오 토큰 응답 +data class KakaoTokenResponse( + val tokenType: String? = null, + val accessToken: String? = null, + val expiresIn: Int? = null, + val refreshToken: String? = null, + val refreshTokenExpiresIn: Int? = null, + //@JsonProperty("scope") + //val scope: String? = null +) + +// 카카오 유저정보 응답 +data class KakaoUserInfoResponse( + val id: Long? = null, + val kakaoAccount: KakaoAccount? = null +) + +data class KakaoAccount( + val email: String? = null, + val profile: Profile? = null, +) + +data class Profile( + val nickname: String? = null, + // etc... +) + +// 네이버 토큰 응답 +data class NaverTokenResponse( + val accessToken: String? = null, + val refreshToken: String? = null, + val tokenType: String? = null, + val expiresIn: Int? = null, + val error: String? = null, + val errorDescription: String? = null +) + +// 네이버 유저정보 응답 +data class NaverUserInfoResponse( + val resultcode: String? = null, + val message: String? = null, + val response: NaverUserInfoDetail? = null +) +data class NaverUserInfoDetail( + val id: String?, + val email: String?, + val nickname: String?, + // etc... +) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt index 29dc370..8c89f46 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/service/UserService.kt @@ -2,6 +2,9 @@ package com.wafflestudio.interpark.user.service import com.wafflestudio.interpark.user.* import com.wafflestudio.interpark.user.controller.User +import com.wafflestudio.interpark.user.persistence.Provider +import com.wafflestudio.interpark.user.persistence.SocialAccountEntity +import com.wafflestudio.interpark.user.persistence.SocialAccountRepository import com.wafflestudio.interpark.user.persistence.UserEntity import com.wafflestudio.interpark.user.persistence.UserIdentityEntity import com.wafflestudio.interpark.user.persistence.UserIdentityRepository @@ -46,14 +49,15 @@ class UserService( email = email, ), ) - userIdentityRepository.save( - UserIdentityEntity( - user = user, - role = role, - hashedPassword = encryptedPassword, - provider = "self", - ), - ) + val userIdentity = + userIdentityRepository.save( + UserIdentityEntity( + user = user, + role = role, + hashedPassword = encryptedPassword, + ), + ) + return User.fromEntity(user) } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 78136a2..eb38d65 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,4 +1,6 @@ spring: + config: + import: optional:file:.env[.properties] datasource: url: 'jdbc:mysql://localhost:3306/testdb' driver-class-name: com.mysql.cj.jdbc.Driver @@ -14,3 +16,32 @@ spring: format_sql: false show_sql: false ddl_auto: create-drop + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: "{baseUrl}/login/oauth2/code/kakao" # 클라이언트에서 처리한다면 제거 가능 + authorization-grant-type: authorization_code + client-name: Kakao + naver: + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_SECRET} + redirect-uri: "{baseUrl}/login/oauth2/code/naver" + authorization-grant-type: authorization_code + client-name: Naver + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id # 사용자 고유 ID + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response +jwt: + secret: ${JWT_SECRET_KEY} \ No newline at end of file diff --git a/src/test/resources/SocialLoginApi.http b/src/test/resources/SocialLoginApi.http new file mode 100644 index 0000000..c0211ef --- /dev/null +++ b/src/test/resources/SocialLoginApi.http @@ -0,0 +1,19 @@ +### 카카오 로그인 +### 1️⃣ 카카오 인가 코드 요청 (웹 브라우저에서 실행하여 직접 로그인) +# {baseUrl}은 카카오에서 등록한 리다이렉트 URI +GET https://kauth.kakao.com/oauth/authorize + ?client_id={{KAKAO_CLIENT_ID}} + &response_type=code + +### 2️⃣ (수동 입력) 받은 인가 코드 저장 +@KAKAO_AUTH_CODE = XXXXXXXXXXXXXXXXXX + +### 3️⃣ 카카오 액세스 토큰 요청 +POST https://kauth.kakao.com/oauth/token +Content-Type: application/x-www-form-urlencoded + +grant_type=authorization_code + &client_id={{KAKAO_CLIENT_ID}} + &client_secret={{KAKAO_CLIENT_SECRET}} + &redirect_uri={{YOUR_REDIRECT_URI}} + &code={{KAKAO_AUTH_CODE}} diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 5109165..7af7790 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -1,4 +1,6 @@ spring: + config: + import: optional:file:.env[.properties] datasource: url: 'jdbc:mysql://localhost:3306/testdb' driver-class-name: com.mysql.cj.jdbc.Driver @@ -13,7 +15,36 @@ spring: show_sql: false profiles: active: dev + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: "{baseUrl}/login/oauth2/code/kakao" # 클라이언트에서 처리한다면 제거 가능 + authorization-grant-type: authorization_code + client-name: Kakao + naver: + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_SECRET} + redirect-uri: "{baseUrl}/login/oauth2/code/naver" + authorization-grant-type: authorization_code + client-name: Naver + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id # 사용자 고유 ID + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response +jwt: + secret: ${JWT_SECRET_KEY} cache: expire-after-write: 1m - maximum-size: 100 + maximum-size: 100 \ No newline at end of file From ded76db852855b87b69cf56939383b6ea6753b63 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Fri, 31 Jan 2025 21:57:22 +0900 Subject: [PATCH 131/162] readme skeleton update --- README.md | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7774af0..08f52c4 100644 --- a/README.md +++ b/README.md @@ -1 +1,112 @@ -# 22-5-team4-server \ No newline at end of file +# 22-5-team4-server + +# 🧇 WaffleTicket 🎫 (Interpark Ticket clone) + + +> 이 프로젝트는 **Spring Boot**를 사용한 **RESTful API 서버**이며, +> 주요 기능은 **사용자 인증/인가(JWT)**, **게시글 CRUD** 등입니다. +> 와플티켓 안드로이드 앱의 백엔드 서버 역할을 합니다. +--- + +## 목차 + +1. [프로젝트 개요](#프로젝트-개요-project-overview) +2. [기술 스택](#기술-스택-tech-stack) +3. [주요 기능](#주요-기능-features) +4. [설치 및 실행](#설치-및-실행-getting-started) +5. [환경 변수 / 설정](#환경-변수--설정-environment-variables) +6. [DB 구조](#db-구조-database-schema) +7. [API 명세](#api-명세-api-documentation) +8. [테스트](#테스트-testing) +9. [배포](#배포-deployment) +10. [라이선스](#라이선스-license) +11. [기여](#기여-contributing) + +--- + +## 프로젝트 개요 (Project Overview) + +**와플티켓 안드로이드 앱 백엔드 서버**는 공연 정보를 등록하고, 사용자가 티켓을 예매/취소할 수 있는 기능을 제공합니다. +- **목적**: 오프라인 공연의 티켓 예매 과정을 온라인 서비스로 전환 +- **주요 특징**: 공연 일정/좌석/가격 관리, 예매/결제/취소, 소셜 로그인 등 +- **추가 내용**: 관리자와 일반 사용자의 접근 권한을 구분하며, 포스터/백드롭 이미지 등 공연 상세 정보를 다룹니다. + +--- + +## 기술 스택 (Tech Stack) + +- **언어(Language)**: +- **프레임워크(Framework)**: +- **DB(Database)**: +- **빌드/의존성 관리**: +- **인증(Authentication)**: JWT (), OAuth 2.0( ) +- **기타**: + - Docker & Docker Compose + - Swagger (Springdoc) for API 문서화 + - AWS (EC2, RDS) 배포 가능 + +| 구분 | 기술 | 비고 | +|--------------|--------------------------------|--------------------------------------| +| Backend | Spring Boot 3 (Kotlin) | Java 23 기반 | +| DB | MySQL 8 | Docker Compose로 컨테이너 실행 가능 | +| Auth | JWT, Social(OAuth2) | Access/Refresh Token 발급, 카카오/네이버 로그인 | +| Infra | AWS (EC2, RDS), Docker | 개발/테스트/운영 환경 분리 | + +--- + +## 주요 기능 (Features) + +1. **공연 관리** + - 관리자 전용: 공연 등록/수정/삭제 (제목, 상세 정보, 일정, 가격, 포스터, 백드롭 이미지 등) + - 카테고리별 공연 목록 조회 +2. **티켓 예매/취소** + - 공연 좌석 조회, 특정 좌석 예매 진행 + - 결제(단순 시뮬레이션 혹은 결제 모듈 연동) + - 예매 취소 시 환불 로직 등 +3. **회원가입 / 로그인** + - **로컬 로그인**: 아이디/비밀번호 + - **소셜 로그인**: 카카오, 네이버 등의 OAuth2 인증 + - JWT 토큰 발급, 재발급(리프레시 토큰) +4. **마이페이지** + - 예매 내역, 예매 취소 내역 조회 + - 공연 찜/즐겨찾기 (선택사항) +5. **관리자 기능** + - 유저 목록 조회(권한 관리) + - 공연 관련 통계, 매출 리포트(선택사항) +6. **캐싱 / 성능 최적화** (선택 사항) + - 공연 목록, 좌석 정보 캐싱 + - 대규모 트래픽 대비 확장성 확보 + +--- + +## 설치 및 실행 (Getting Started) + +### 사전 요구사항 (Prerequisites) + +- **Java 17** 이상 / Kotlin +- **Gradle 7.x** +- **MySQL 8.x** (혹은 Docker로 MySQL 실행) +- **Git** (프로젝트 클론) + +### 설치 / 실행 (Installation / Run) + +1. **레포지토리 클론** + ```bash + git clone https://github.com/yourusername/ticketing-backend.git + cd ticketing-backend + ``` + +2. **환경 변수 설정** + - `.env` 파일 또는 `application.yml`에 DB, JWT 시크릿, 소셜 로그인 client-id 등 설정 + - 자세한 사항은 [환경 변수 / 설정](#환경-변수--설정-environment-variables) 참고 + +3. **의존성 설치 & 빌드** + ```bash + ./gradlew clean build + ``` + +4. **애플리케이션 실행** + ```bash + ./gradlew bootRun + ``` + - 기본 포트: `http://localhost:8080` \ No newline at end of file From 0dc08864d715d0e4d1c8f53bab58b4355bcc75ce Mon Sep 17 00:00:00 2001 From: DoHyeon Kim Date: Fri, 31 Jan 2025 22:13:27 +0900 Subject: [PATCH 132/162] readme.md skeleton update (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * merge test - generate performancehallentity.kt * feat: searchPerformance * chore: 도커 컴포즈 dockerfile 작성해서 도커 이미지 잘 만들어지고 http 파일로 테스트까지 가능한 상태입니다 * modify searchPerformance * add: docker-compose * modify build.gradle.kts * add wildcard import in UserService.kt * change Performance attribute 'genre' to 'category' * change category to ENUM Type * add some Init Datas * add some Init Datas * assign posterimageURL * add: Entity 엔티티 작업중 * assign posterimageURL * add: SeatService 임시저장하겠습니다 * feat: SeatService 좌석 확인, 예매, 예매 취소 기능 구현 아직 동시성 처리 안됐습니다 * feat: Add Controller 컨트롤러 추가했습니다 곧 테스트 코드 추가해보겠습니다 * modify searchPerformance, GET요청은 RequestBody를 사용할 수 없다고 하여 다시 RequestParam을 사용하는 원래 방식으로 변경 * feat: SeatCreation performanceHallId와 type을 받아 좌석엔티티들을 자동으로 생성하는 서비스 함수와 performanceEventId를 받아 빈 예약 가능 엔티티들을 자동을 생성하는 서비스를 만들었습니다 추후 createPerformanceHall과 createPerformanceEvent함수를 수정해서 과정을 더 자동화할 수 있을 것 같습니다 * add: SeatIntegrationTest SeatIntegrationTest 만들었습니다 테스트 전부 통과하며 빌드 성공합니다 다만 테스트에 mysql 데이터베이스가 쓰이며 초기화가 자동으로 되지 않아 문제가 생기니 이는 개선이 필요합니다 * chore: use h2 while test application.yaml 파일을 테스트 코드를 위해 하나 추가해줬습니다 테스트코드에서 @Transactional까지 사용한다면 테스트 코드가 서로 영향을 끼치지 않게 됩니다 * fix: Seat Reservation reservation 바뀐 정보 반영하도록 save 사용했고, Detail확인하는 영역은 컨트롤러까지 분리했습니다 * style: seat ktlint seat폴더의 파일들에 ktlint 적용 * modify MakeFile * 공연 검색 기능 구현 * fromEntity parameter change: receive Entities -> receive DTOs * modify searchPerformance endpoint URL * chore: makefile OS 따라서 다르게 작동하게 수정 * 공연 1개 상세 정보 반환 * minor modify: change how null type is handled * 초기 데이터 수정, 공연이벤트는 일단 공연 기간의 첫날과 마지막날만 생성하였음 * modify DataInitializer, SeatIntegrationTest * add PerformanceDetail Uri * make PerformanceItegrationTest.kt * feat: 예매 동시성 동시성 처리 완료 동시성 처리는 잘 되지만, 테스트 코드 작성이 많이 까다로움 * feat: Get My Reservation Api 유저가 로그인한채로 본인의 예약 목록을 확인할 수 있는 기능 추가 * feat: Seat Get TestCode Get API가 정상작동하는지 테스트코드 추가 * modify SearchPerformanceResponse * check auth for create, delete performance * check auth for create, delete performanceEvent, performanceHall * add posterUri attr to BriefPerformanceDetail * minor change * feat: 예매목록 Brief로 주기 기존에 id만 주던 것을 Brief 데이터의 리스트를 주는 것으로 수정 signin을 할 때 User도 같이 반환하도록 수정 * fix: 로그아웃, 토큰 재발급 버그 수정, 엔드포인트 수정 * add UserIdentityNotFoundException * modify PerformanceDurtion * backup package to * modify endpoint * applying spring security * setup .env * fix .http localhost port number * apply spring security * apply spring security * minor * remove .env from tracked files * gitignore * remove unused method * minor * minor * add new line at EOF * modify jwtfilter comment * convert Instant to KoreanLocalDateTime in PerformanceEventDTO * add permission * throw AuthenticateException when accessToken is not valid * save * roll back 401 -> 403 when access denied * setup .env * social login 구현중 중간 save * add SocialLoginApi.http * remove .env * write swagger api for socialauthcontroller * modify social login api docs description * add social login query paramter explanations * social endpoint permitall * social login receive socialAccessToken, not authorizatoinCode * modify social login explanation * rollback userservice.signup() * rollback signup * readme skeleton update --------- Co-authored-by: ChungPlusPlus --- README.md | 113 +++++++++++++++++++++++++++- src/test/resources/application.yaml | 2 +- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7774af0..08f52c4 100644 --- a/README.md +++ b/README.md @@ -1 +1,112 @@ -# 22-5-team4-server \ No newline at end of file +# 22-5-team4-server + +# 🧇 WaffleTicket 🎫 (Interpark Ticket clone) + + +> 이 프로젝트는 **Spring Boot**를 사용한 **RESTful API 서버**이며, +> 주요 기능은 **사용자 인증/인가(JWT)**, **게시글 CRUD** 등입니다. +> 와플티켓 안드로이드 앱의 백엔드 서버 역할을 합니다. +--- + +## 목차 + +1. [프로젝트 개요](#프로젝트-개요-project-overview) +2. [기술 스택](#기술-스택-tech-stack) +3. [주요 기능](#주요-기능-features) +4. [설치 및 실행](#설치-및-실행-getting-started) +5. [환경 변수 / 설정](#환경-변수--설정-environment-variables) +6. [DB 구조](#db-구조-database-schema) +7. [API 명세](#api-명세-api-documentation) +8. [테스트](#테스트-testing) +9. [배포](#배포-deployment) +10. [라이선스](#라이선스-license) +11. [기여](#기여-contributing) + +--- + +## 프로젝트 개요 (Project Overview) + +**와플티켓 안드로이드 앱 백엔드 서버**는 공연 정보를 등록하고, 사용자가 티켓을 예매/취소할 수 있는 기능을 제공합니다. +- **목적**: 오프라인 공연의 티켓 예매 과정을 온라인 서비스로 전환 +- **주요 특징**: 공연 일정/좌석/가격 관리, 예매/결제/취소, 소셜 로그인 등 +- **추가 내용**: 관리자와 일반 사용자의 접근 권한을 구분하며, 포스터/백드롭 이미지 등 공연 상세 정보를 다룹니다. + +--- + +## 기술 스택 (Tech Stack) + +- **언어(Language)**: +- **프레임워크(Framework)**: +- **DB(Database)**: +- **빌드/의존성 관리**: +- **인증(Authentication)**: JWT (), OAuth 2.0( ) +- **기타**: + - Docker & Docker Compose + - Swagger (Springdoc) for API 문서화 + - AWS (EC2, RDS) 배포 가능 + +| 구분 | 기술 | 비고 | +|--------------|--------------------------------|--------------------------------------| +| Backend | Spring Boot 3 (Kotlin) | Java 23 기반 | +| DB | MySQL 8 | Docker Compose로 컨테이너 실행 가능 | +| Auth | JWT, Social(OAuth2) | Access/Refresh Token 발급, 카카오/네이버 로그인 | +| Infra | AWS (EC2, RDS), Docker | 개발/테스트/운영 환경 분리 | + +--- + +## 주요 기능 (Features) + +1. **공연 관리** + - 관리자 전용: 공연 등록/수정/삭제 (제목, 상세 정보, 일정, 가격, 포스터, 백드롭 이미지 등) + - 카테고리별 공연 목록 조회 +2. **티켓 예매/취소** + - 공연 좌석 조회, 특정 좌석 예매 진행 + - 결제(단순 시뮬레이션 혹은 결제 모듈 연동) + - 예매 취소 시 환불 로직 등 +3. **회원가입 / 로그인** + - **로컬 로그인**: 아이디/비밀번호 + - **소셜 로그인**: 카카오, 네이버 등의 OAuth2 인증 + - JWT 토큰 발급, 재발급(리프레시 토큰) +4. **마이페이지** + - 예매 내역, 예매 취소 내역 조회 + - 공연 찜/즐겨찾기 (선택사항) +5. **관리자 기능** + - 유저 목록 조회(권한 관리) + - 공연 관련 통계, 매출 리포트(선택사항) +6. **캐싱 / 성능 최적화** (선택 사항) + - 공연 목록, 좌석 정보 캐싱 + - 대규모 트래픽 대비 확장성 확보 + +--- + +## 설치 및 실행 (Getting Started) + +### 사전 요구사항 (Prerequisites) + +- **Java 17** 이상 / Kotlin +- **Gradle 7.x** +- **MySQL 8.x** (혹은 Docker로 MySQL 실행) +- **Git** (프로젝트 클론) + +### 설치 / 실행 (Installation / Run) + +1. **레포지토리 클론** + ```bash + git clone https://github.com/yourusername/ticketing-backend.git + cd ticketing-backend + ``` + +2. **환경 변수 설정** + - `.env` 파일 또는 `application.yml`에 DB, JWT 시크릿, 소셜 로그인 client-id 등 설정 + - 자세한 사항은 [환경 변수 / 설정](#환경-변수--설정-environment-variables) 참고 + +3. **의존성 설치 & 빌드** + ```bash + ./gradlew clean build + ``` + +4. **애플리케이션 실행** + ```bash + ./gradlew bootRun + ``` + - 기본 포트: `http://localhost:8080` \ No newline at end of file diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 632627c..7af7790 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -47,4 +47,4 @@ jwt: cache: expire-after-write: 1m - maximum-size: 100 + maximum-size: 100 \ No newline at end of file From 5ef75eb3e495c344bec0451b30133c3ac5612750 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sat, 1 Feb 2025 00:10:22 +0900 Subject: [PATCH 133/162] =?UTF-8?q?feat:=20data=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interpark/config/DataInitializer.kt | 98 +++++++++++++++++-- .../interpark/pagination/CursorException.kt | 2 +- 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index 3db37db..d4a660d 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -75,6 +75,26 @@ class DataInitializer( address = "서울 종로구 세종대로", maxAudience = 100 ) + performanceHallService.createPerformanceHall( + name = "동덕여자대학교 공연예술센터 코튼홀", + address = "서울특별시 종로구 동숭길 134", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "대학로 자유극장", + address = "서울특별시 종로구 대학로12길", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "인터파크 서경스퀘어", + address = "서울 종로구 동숭길", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "샤롯데씨어터", + address = "서울특별시 송파구 잠실동", + maxAudience = 100 + ) // 2) Performance 데이터 넣기 performanceService.createPerformance( @@ -161,33 +181,68 @@ class DataInitializer( posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24017573_p.gif", backdropImageUri = "http://example.com/backdrop/mom.jpg" ) + performanceService.createPerformance( + title = "종의 기원", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24016611-01.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24016611_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "하트셉수트", + detail = "https://ticketimage.interpark.com/Play/ITM/Data/Modify/2025/1/2025013111034957.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25001185_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "그해 여름", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24017986-08.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24017986_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "명성황후", + detail = "https://ticketimage.interpark.com/P00041072025/01/20/762a8f19.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/P0/P0004107_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "알라딘", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24012498-18.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24012498_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) // 3) Performance Event 데이터 넣기 val performanceEvents = listOf( Triple( "뮤지컬 지킬앤하이드", "블루스퀘어 신한카드홀", - generateDateRange("2024-11-29","2025-05-18","16:00:00","18:00:00") + generateDateRange("2025-02-05","2025-05-18","16:00:00","18:00:00") ), Triple( "마타하리", "LG아트센터 서울 SIGNATURE홀", - generateDateRange("2024-12-05", "2025-03-02", "16:00:00","18:00:00") + generateDateRange("2025-02-03", "2025-03-02", "16:00:00","18:00:00") ), Triple( "웃는남자", "예술의전당 오페라극장", - generateDateRange("2025-01-09", "2025-03-09", "16:00:00","18:00:00") + generateDateRange("2025-02-09", "2025-03-09", "16:00:00","18:00:00") ), Triple( "2025 기리보이 콘서트", "블루스퀘어 마스터카드홀", - generateDateRange("2025-02-01", "2025-02-02", "16:00:00","18:00:00") + generateDateRange("2025-02-10", "2025-02-02", "16:00:00","18:00:00") ), Triple( "2025 검정치마 단독공연", "올림픽공원 올림픽홀", - generateDateRange("2025-02-01", "2025-02-02", "16:00:00","18:00:00") + generateDateRange("2025-02-03", "2025-02-02", "16:00:00","18:00:00") ), Triple( "콜드플레이 내한공연", @@ -207,22 +262,47 @@ class DataInitializer( Triple( "발레의 별빛, 글로벌 발레스타 초청 갈라공연", "세종문화회관 대극장", - generateDateRange("2025-01-11", "2025-01-12", "16:00:00","18:00:00") + generateDateRange("2025-03-11", "2025-04-12", "16:00:00","18:00:00") ), Triple( "연극 애나엑스", "LG아트센터 서울 U+ 스테이지", - generateDateRange("2025-01-28", "2025-03-16", "16:00:00","18:00:00") + generateDateRange("2025-03-11", "2025-04-12", "16:00:00","18:00:00") ), Triple( "연극 타인의 삶", "LG아트센터 서울 U+ 스테이지", - generateDateRange("2025-01-28", "2025-03-16", "16:00:00","18:00:00") + generateDateRange("2025-02-28", "2025-03-16", "16:00:00","18:00:00") ), Triple( "세일즈맨의 죽음", "세종문화회관 M씨어터", - generateDateRange("2025-01-07", "2025-03-03", "16:00:00","18:00:00") + generateDateRange("2025-02-07", "2025-03-03", "16:00:00","18:00:00") + ), + Triple( + "종의 기원", + "동덕여자대학교 공연예술센터 코튼홀", + generateDateRange("2025-02-04", "2025-05-03", "16:00:00","18:00:00") + ), + Triple( + "하트셉수트", + "대학로 자유극장", + generateDateRange("2025-02-04", "2025-06-01", "16:00:00","18:00:00") + ), + Triple( + "그해 여름", + "인터파크 서경스퀘어", + generateDateRange("2025-02-12", "2025-03-02", "16:00:00","18:00:00") + ), + Triple( + "명성황후", + "세종문화회관 대극장", + generateDateRange("2025-02-15", "2025-03-30", "16:00:00","18:00:00") + ), + Triple( + "알라딘", + "샤롯데씨어터", + generateDateRange("2025-03-01", "2025-06-22", "16:00:00","18:00:00") ) ) diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt index ccda7e1..0a3dd6a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt @@ -33,4 +33,4 @@ class CursorNotComparableException : CursorException( errorCode = 0, httpStatusCode = HttpStatus.BAD_REQUEST, msg = "Cursor not comparable", -) \ No newline at end of file +) From 1f69f8c4ee75b5fb75a173d3cd1cd3f3fad5c8bc Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sat, 1 Feb 2025 00:49:02 +0900 Subject: [PATCH 134/162] =?UTF-8?q?feat:=20MUSICAL=20data=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interpark/config/DataInitializer.kt | 84 ++++++++++++++++++- .../wafflestudio/interpark/PaginationTest.kt | 4 +- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index d4a660d..1ba59ac 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -95,6 +95,26 @@ class DataInitializer( address = "서울특별시 송파구 잠실동", maxAudience = 100 ) + performanceHallService.createPerformanceHall( + name = "디큐브 링크아트센터", + address = "서울시 구로구 경인로 662 7층", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "예술의전당 CJ 토월극장", + address = " 서울특별시 서초구 서초동", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "인터파크 유니플렉스 1관", + address = "서울특별시 종로구 동숭동", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "인터파크 유니플렉스 2관", + address = "서울특별시 종로구 동숭동", + maxAudience = 100 + ) // 2) Performance 데이터 넣기 performanceService.createPerformance( @@ -216,6 +236,34 @@ class DataInitializer( posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24012498_p.gif", backdropImageUri = "http://example.com/backdrop/mom.jpg" ) + performanceService.createPerformance( + title = "베르테르 25주년 공연", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24017198-07.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24017198_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "시라노", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24014885-18.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24014885_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "여신님이 보고 계셔", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24014618-03.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24014618_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "빨래", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24006709-30.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24006709_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) // 3) Performance Event 데이터 넣기 val performanceEvents = listOf( @@ -303,7 +351,41 @@ class DataInitializer( "알라딘", "샤롯데씨어터", generateDateRange("2025-03-01", "2025-06-22", "16:00:00","18:00:00") - ) + ), + Triple( + "베르테르 25주년 공연", + "디큐브 링크아트센터", + generateDateRange("2025-02-15", "2025-02-16", "16:00:00","18:00:00") + ), + Triple( + "시라노", + "예술의전당 CJ 토월극장", + generateDateRange("2025-02-15", "2025-02-16", "16:00:00","18:00:00") + ), + Triple( + "여신님이 보고 계셔", + "인터파크 유니플렉스 1관", + listOf( + listOf("2025-02-07T16:00:00", "2025-02-07T18:00:00"), + listOf("2025-02-08T16:00:00", "2025-02-08T18:00:00"), + listOf("2025-02-09T16:00:00", "2025-02-09T18:00:00"), + listOf("2025-02-14T16:00:00", "2025-02-14T18:00:00"), + listOf("2025-02-15T16:00:00", "2025-02-15T18:00:00"), + listOf("2025-02-16T16:00:00", "2025-02-16T18:00:00"), + ) + ), + Triple( + "빨래", + "인터파크 유니플렉스 2관", + listOf( + listOf("2025-02-07T16:00:00", "2025-02-07T18:00:00"), + listOf("2025-02-08T16:00:00", "2025-02-08T18:00:00"), + listOf("2025-02-09T16:00:00", "2025-02-09T18:00:00"), + listOf("2025-02-14T16:00:00", "2025-02-14T18:00:00"), + listOf("2025-02-15T16:00:00", "2025-02-15T18:00:00"), + listOf("2025-02-16T16:00:00", "2025-02-16T18:00:00"), + ) + ), ) performanceEvents.forEach { (performanceTitle, hallName, eventTimes) -> diff --git a/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt index cb41ce1..6fa9829 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt @@ -94,7 +94,7 @@ constructor( @Test fun `공연 전체 조회 페이지네이션 테스트`() { var cursor: String? = null - val maxIteration = 4 + val maxIteration = 15 var iterations = 0 var totalItems = 0 @@ -137,7 +137,7 @@ constructor( @Test fun `공연 일부 조회 페이지네이션 테스트`() { var cursor: String? = null - val maxIteration = 4 + val maxIteration = 15 var iterations = 0 var totalItems = 0 From cf73e454fef65abbd51a784bf919987adadb5aa2 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sat, 1 Feb 2025 14:48:24 +0900 Subject: [PATCH 135/162] =?UTF-8?q?feat:=20PLAY=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interpark/config/DataInitializer.kt | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index 1ba59ac..b1a2afd 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -115,6 +115,51 @@ class DataInitializer( address = "서울특별시 종로구 동숭동", maxAudience = 100 ) + performanceHallService.createPerformanceHall( + name = "콘텐츠박스", + address = "서울시 종로구 동숭동", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "지인시어터", + address = "서울특별시 종로구 동숭길 25", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "링크아트센터 벅스홀", + address = "서울특별시 종로구 대학로", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "라온아트홀", + address = "서울특별시 종로구 대학로", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "국립극장 달오름극장", + address = "서울 중구 장충단로", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "대학로 아트하우스", + address = "서울특별시 종로구 대학로10길", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "대학로 제나아트홀", + address = "서울특별시 종로구 대학로", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "예술의전당 자유소극장", + address = "서울특별시 서초구 서초동", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "KNN시어터", + address = "부산광역시 해운대구", + maxAudience = 100 + ) // 2) Performance 데이터 넣기 performanceService.createPerformance( @@ -264,6 +309,69 @@ class DataInitializer( posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24006709_p.gif", backdropImageUri = "http://example.com/backdrop/mom.jpg" ) + performanceService.createPerformance( + title = "쉬어매드니스", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24006928-20.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24006928_p.gif", + backdropImageUri = "http://example.com/backdrop/shearmadness.jpg" + ) + performanceService.createPerformance( + title = "죽여주는 이야기", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/23008491-103.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/23/23008491_p.gif", + backdropImageUri = "http://example.com/backdrop/killerstory.jpg" + ) + performanceService.createPerformance( + title = "꽃의 비밀", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24018192-06.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24018192_p.gif", + backdropImageUri = "http://example.com/backdrop/flowersecret.jpg" + ) + performanceService.createPerformance( + title = "한뼘사이", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/22014277-143.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/22/22014277_p.gif", + backdropImageUri = "http://example.com/backdrop/handspan.jpg" + ) + performanceService.createPerformance( + title = "붉은 낙엽", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24016741-11.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24016741_p.gif", + backdropImageUri = "http://example.com/backdrop/redleaf.jpg" + ) + performanceService.createPerformance( + title = "사랑해 엄마", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24015841-19.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24015841_p.gif", + backdropImageUri = "http://example.com/backdrop/loveyoumom.jpg" + ) + performanceService.createPerformance( + title = "사내연애 보고서", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24008626-24.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24008626_p.gif", + backdropImageUri = "http://example.com/backdrop/officeromance.jpg" + ) + performanceService.createPerformance( + title = "바닷마을 다이어리", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24017992-12.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24017992_p.gif", + backdropImageUri = "http://example.com/backdrop/diaryofseaside.jpg" + ) + performanceService.createPerformance( + title = "불편한 편의점", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24004660-31.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24004660_p.gif", + backdropImageUri = "http://example.com/backdrop/inconvenientstore.jpg" + ) // 3) Performance Event 데이터 넣기 val performanceEvents = listOf( @@ -386,6 +494,51 @@ class DataInitializer( listOf("2025-02-16T16:00:00", "2025-02-16T18:00:00"), ) ), + Triple( + "쉬어매드니스", + "콘텐츠박스", + generateDateRange("2025-02-15", "2025-02-18", "16:00:00","18:00:00") + ), + Triple( + "죽여주는 이야기", + "지인시어터", + generateDateRange("2025-02-15", "2025-02-18", "16:00:00","18:00:00") + ), + Triple( + "꽃의 비밀", + "링크아트센터 벅스홀", + generateDateRange("2025-02-15", "2025-02-28", "16:00:00","18:00:00") + ), + Triple( + "한뼘사이", + "라온아트홀", + generateDateRange("2025-02-28", "2025-03-01", "16:00:00","18:00:00") + ), + Triple( + "붉은 낙엽", + "국립극장 달오름극장", + generateDateRange("2025-02-20", "2025-02-25", "16:00:00","18:00:00") + ), + Triple( + "사랑해 엄마", + "대학로 아트하우스", + generateDateRange("2025-02-20", "2025-02-25", "16:00:00","18:00:00") + ), + Triple( + "사내연애 보고서", + "대학로 제나아트홀", + generateDateRange("2025-02-20", "2025-02-25", "16:00:00","18:00:00") + ), + Triple( + "바닷마을 다이어리", + "예술의전당 자유소극장", + generateDateRange("2025-03-01", "2025-03-31", "16:00:00","18:00:00") + ), + Triple( + "불편한 편의점", + "KNN시어터", + generateDateRange("2025-02-03", "2025-02-28", "16:00:00","18:00:00") + ), ) performanceEvents.forEach { (performanceTitle, hallName, eventTimes) -> From e53e1229818a7ecc54fc6971942a5f8928e49dc0 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sat, 1 Feb 2025 15:15:39 +0900 Subject: [PATCH 136/162] =?UTF-8?q?feat:=20CLASSIC=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interpark/config/DataInitializer.kt | 146 ++++++++++++++++-- 1 file changed, 137 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index b1a2afd..d371365 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -160,6 +160,26 @@ class DataInitializer( address = "부산광역시 해운대구", maxAudience = 100 ) + performanceHallService.createPerformanceHall( + name = "용인포은아트홀", + address = "경기도 용인시 수지구 포은대로", + maxAudience = 1000 + ) + performanceHallService.createPerformanceHall( + name = "롯데콘서트홀", + address = "서울특별시 송파구 올림픽로", + maxAudience = 2036 + ) + performanceHallService.createPerformanceHall( + name = "국립정동극장", + address = "서울 중구 정동길", + maxAudience = 500 + ) + performanceHallService.createPerformanceHall( + name = "유니버셜아트센터", + address = "서울시 광진구 능동", + maxAudience = 1500 + ) // 2) Performance 데이터 넣기 performanceService.createPerformance( @@ -314,63 +334,126 @@ class DataInitializer( detail = "https://ticketimage.interpark.com/Play/image/etc/24/24006928-20.jpg", category = PerformanceCategory.PLAY, posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24006928_p.gif", - backdropImageUri = "http://example.com/backdrop/shearmadness.jpg" + backdropImageUri = "http://example.com/backdrop/mom.jpg" ) performanceService.createPerformance( title = "죽여주는 이야기", detail = "https://ticketimage.interpark.com/Play/image/etc/25/23008491-103.jpg", category = PerformanceCategory.PLAY, posterUri = "https://ticketimage.interpark.com/Play/image/large/23/23008491_p.gif", - backdropImageUri = "http://example.com/backdrop/killerstory.jpg" + backdropImageUri = "http://example.com/backdrop/mom.jpg" ) performanceService.createPerformance( title = "꽃의 비밀", detail = "https://ticketimage.interpark.com/Play/image/etc/25/24018192-06.jpg", category = PerformanceCategory.PLAY, posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24018192_p.gif", - backdropImageUri = "http://example.com/backdrop/flowersecret.jpg" + backdropImageUri = "http://example.com/backdrop/mom.jpg" ) performanceService.createPerformance( title = "한뼘사이", detail = "https://ticketimage.interpark.com/Play/image/etc/24/22014277-143.jpg", category = PerformanceCategory.PLAY, posterUri = "https://ticketimage.interpark.com/Play/image/large/22/22014277_p.gif", - backdropImageUri = "http://example.com/backdrop/handspan.jpg" + backdropImageUri = "http://example.com/backdrop/mom.jpg" ) performanceService.createPerformance( title = "붉은 낙엽", detail = "https://ticketimage.interpark.com/Play/image/etc/25/24016741-11.jpg", category = PerformanceCategory.PLAY, posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24016741_p.gif", - backdropImageUri = "http://example.com/backdrop/redleaf.jpg" + backdropImageUri = "http://example.com/backdrop/mom.jpg" ) performanceService.createPerformance( title = "사랑해 엄마", detail = "https://ticketimage.interpark.com/Play/image/etc/25/24015841-19.jpg", category = PerformanceCategory.PLAY, posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24015841_p.gif", - backdropImageUri = "http://example.com/backdrop/loveyoumom.jpg" + backdropImageUri = "http://example.com/backdrop/mom.jpg" ) performanceService.createPerformance( title = "사내연애 보고서", detail = "https://ticketimage.interpark.com/Play/image/etc/24/24008626-24.jpg", category = PerformanceCategory.PLAY, posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24008626_p.gif", - backdropImageUri = "http://example.com/backdrop/officeromance.jpg" + backdropImageUri = "http://example.com/backdrop/mom.jpg" ) performanceService.createPerformance( title = "바닷마을 다이어리", detail = "https://ticketimage.interpark.com/Play/image/etc/25/24017992-12.jpg", category = PerformanceCategory.PLAY, posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24017992_p.gif", - backdropImageUri = "http://example.com/backdrop/diaryofseaside.jpg" + backdropImageUri = "http://example.com/backdrop/mom.jpg" ) performanceService.createPerformance( title = "불편한 편의점", detail = "https://ticketimage.interpark.com/Play/image/etc/24/24004660-31.jpg", category = PerformanceCategory.PLAY, posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24004660_p.gif", - backdropImageUri = "http://example.com/backdrop/inconvenientstore.jpg" + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "지브리 OST 콘서트 : 디 오케스트라", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000133-03.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000133_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "라흐마니노프", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000715-01.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000715_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "코리안챔버오케스트라 창단 60주년 기념", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24018282-02.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24018282_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "토요키즈클래식", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000321-02.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000321_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "IBK기업은행과 함께하는 예술의전당 토요콘서트", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000463-02.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000463_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "지브리&디즈니 영화음악 FESTA", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000640-05.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000640_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "광대", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24018235-04.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24018235_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "백건우와 모차르트 〈Program Ⅱ〉", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24017702-06.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24017702_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "피아노 파 드 되 - Dancing with Pierrot", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24018007-04.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24018007_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" ) // 3) Performance Event 데이터 넣기 @@ -539,6 +622,51 @@ class DataInitializer( "KNN시어터", generateDateRange("2025-02-03", "2025-02-28", "16:00:00","18:00:00") ), + Triple( + "지브리 OST 콘서트 : 디 오케스트라", + "예술의전당 콘서트홀", + generateDateRange("2025-02-15", "2025-02-18", "16:00:00","18:00:00") + ), + Triple( + "라흐마니노프", + "예술의전당 콘서트홀", + generateDateRange("2025-02-10", "2025-02-10", "16:00:00","18:00:00") + ), + Triple( + "코리안챔버오케스트라 창단 60주년 기념", + "예술의전당 콘서트홀", + generateDateRange("2025-03-02", "2025-03-02", "16:00:00","18:00:00") + ), + Triple( + "토요키즈클래식", + "용인포은아트홀", + generateDateRange("2025-02-15", "2025-06-21", "16:00:00","18:00:00") + ), + Triple( + "IBK기업은행과 함께하는 예술의전당 토요콘서트", + "예술의전당 콘서트홀", + generateDateRange("2025-02-15", "2025-02-15", "16:00:00","18:00:00") + ), + Triple( + "지브리&디즈니 영화음악 FESTA", + "롯데콘서트홀", + generateDateRange("2025-03-05", "2025-03-05", "16:00:00","18:00:00") + ), + Triple( + "광대", + "국립정동극장", + generateDateRange("2025-02-12", "2025-02-21", "16:00:00","18:00:00") + ), + Triple( + "백건우와 모차르트 〈Program Ⅱ〉", + "예술의전당 콘서트홀", + generateDateRange("2025-03-10", "2025-03-10", "16:00:00","18:00:00") + ), + Triple( + "피아노 파 드 되 - Dancing with Pierrot", + "유니버셜아트센터", + generateDateRange("2025-02-24", "2025-03-07", "16:00:00","18:00:00") + ), ) performanceEvents.forEach { (performanceTitle, hallName, eventTimes) -> From 95a87a561d84763b032428b1beddb985cd9ed045 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sat, 1 Feb 2025 15:38:45 +0900 Subject: [PATCH 137/162] =?UTF-8?q?feat:=20CONCERT=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interpark/config/DataInitializer.kt | 141 +++++++++++++++++- .../interpark/PerformanceIntegrationTest.kt | 2 +- 2 files changed, 138 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index d371365..1bbf5fa 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -163,22 +163,47 @@ class DataInitializer( performanceHallService.createPerformanceHall( name = "용인포은아트홀", address = "경기도 용인시 수지구 포은대로", - maxAudience = 1000 + maxAudience = 100 ) performanceHallService.createPerformanceHall( name = "롯데콘서트홀", address = "서울특별시 송파구 올림픽로", - maxAudience = 2036 + maxAudience = 100 ) performanceHallService.createPerformanceHall( name = "국립정동극장", address = "서울 중구 정동길", - maxAudience = 500 + maxAudience = 100 ) performanceHallService.createPerformanceHall( name = "유니버셜아트센터", address = "서울시 광진구 능동", - maxAudience = 1500 + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "서울랜드", + address = "경기도 과천시 광명로", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "KSPO DOME", + address = "서울특별시 송파구 올림픽로", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "인스파이어 아레나", + address = "인천광역시 중구 공항문화로", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "광주예술의전당 대극장", + address = "광주광역시 북구 북문대로", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "의정부예술의전당 대극장", + address = "경기도 의정부시 의정로 1", + maxAudience = 100 ) // 2) Performance 데이터 넣기 @@ -455,6 +480,69 @@ class DataInitializer( posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24018007_p.gif", backdropImageUri = "http://example.com/backdrop/mom.jpg" ) + performanceService.createPerformance( + title = "정동원棟동 이야기話화 3rd 전국투어 콘서트", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25001118-01.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25001118_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "2025 폴킴 소극장 콘서트", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000089-04.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000089_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "2025 World DJ Festival", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24010212-04.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24010212_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "j-hope Tour ‘HOPE ON THE STAGE’ in SEOUL", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000014-01.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000014_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "TAEYANG 2025 TOUR", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24018375-04.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000516_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "“TAK SHOW3” - 앙코르", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24018317-05.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24018317_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "2025 윤하 앵콜 콘서트 〈GROWTH THEORY : Final Edition〉", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000432-02.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000432_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "2024-25 Theatre 이문세", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000934-01.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000934_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "김창옥 토크콘서트 시즌4", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000115-01.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000115_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) // 3) Performance Event 데이터 넣기 val performanceEvents = listOf( @@ -667,6 +755,51 @@ class DataInitializer( "유니버셜아트센터", generateDateRange("2025-02-24", "2025-03-07", "16:00:00","18:00:00") ), + Triple( + "정동원棟동 이야기話화 3rd 전국투어 콘서트", + "올림픽공원 올림픽홀", + generateDateRange("2025-03-28", "2025-03-30", "16:00:00","18:00:00") + ), + Triple( + "2025 폴킴 소극장 콘서트", + "블루스퀘어 마스터카드홀", + generateDateRange("2025-02-08", "2025-02-16", "16:00:00","18:00:00") + ), + Triple( + "2025 World DJ Festival", + "서울랜드", + generateDateRange("2025-06-14", "2025-06-15", "16:00:00","18:00:00") + ), + Triple( + "j-hope Tour ‘HOPE ON THE STAGE’ in SEOUL", + "KSPO DOME", + generateDateRange("2025-02-28", "2025-03-02", "16:00:00","18:00:00") + ), + Triple( + "TAEYANG 2025 TOUR", + "인스파이어 아레나", + generateDateRange("2025-02-05", "2025-02-05", "16:00:00","18:00:00") + ), + Triple( + "“TAK SHOW3” - 앙코르", + "KSPO DOME", + generateDateRange("2025-02-22", "2025-02-23", "16:00:00","18:00:00") + ), + Triple( + "2025 윤하 앵콜 콘서트 〈GROWTH THEORY : Final Edition〉", + "KSPO DOME", + generateDateRange("2025-02-14", "2025-02-16", "16:00:00","18:00:00") + ), + Triple( + "2024-25 Theatre 이문세", + "광주예술의전당 대극장", + generateDateRange("2025-04-11", "2025-04-12", "16:00:00","18:00:00") + ), + Triple( + "김창옥 토크콘서트 시즌4", + "의정부예술의전당 대극장", + generateDateRange("2025-03-16", "2025-03-16", "16:00:00","18:00:00") + ), ) performanceEvents.forEach { (performanceTitle, hallName, eventTimes) -> diff --git a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt index 76e6b3e..de20399 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt @@ -149,7 +149,7 @@ constructor( .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().`is`(200)) .andExpect(jsonPath("$").isArray) // 응답이 배열인지 확인 - .andExpect(jsonPath("$.length()").value(3)) // 배열의 길이가 0인지 확인 + .andExpect(jsonPath("$.length()").value(12)) // 배열의 길이가 0인지 확인 } @Test From 29bececb3edb686a30875717112e3e83164ae315 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus <153504213+ChungPlusPlus@users.noreply.github.com> Date: Sat, 1 Feb 2025 15:42:52 +0900 Subject: [PATCH 138/162] =?UTF-8?q?=EA=B3=B5=EC=97=B0=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * merge test - generate performancehallentity.kt * feat: searchPerformance * chore: 도커 컴포즈 dockerfile 작성해서 도커 이미지 잘 만들어지고 http 파일로 테스트까지 가능한 상태입니다 * modify searchPerformance * add: docker-compose * modify build.gradle.kts * add wildcard import in UserService.kt * change Performance attribute 'genre' to 'category' * change category to ENUM Type * add some Init Datas * add some Init Datas * assign posterimageURL * add: Entity 엔티티 작업중 * assign posterimageURL * add: SeatService 임시저장하겠습니다 * feat: SeatService 좌석 확인, 예매, 예매 취소 기능 구현 아직 동시성 처리 안됐습니다 * feat: Add Controller 컨트롤러 추가했습니다 곧 테스트 코드 추가해보겠습니다 * modify searchPerformance, GET요청은 RequestBody를 사용할 수 없다고 하여 다시 RequestParam을 사용하는 원래 방식으로 변경 * feat: SeatCreation performanceHallId와 type을 받아 좌석엔티티들을 자동으로 생성하는 서비스 함수와 performanceEventId를 받아 빈 예약 가능 엔티티들을 자동을 생성하는 서비스를 만들었습니다 추후 createPerformanceHall과 createPerformanceEvent함수를 수정해서 과정을 더 자동화할 수 있을 것 같습니다 * add: SeatIntegrationTest SeatIntegrationTest 만들었습니다 테스트 전부 통과하며 빌드 성공합니다 다만 테스트에 mysql 데이터베이스가 쓰이며 초기화가 자동으로 되지 않아 문제가 생기니 이는 개선이 필요합니다 * chore: use h2 while test application.yaml 파일을 테스트 코드를 위해 하나 추가해줬습니다 테스트코드에서 @Transactional까지 사용한다면 테스트 코드가 서로 영향을 끼치지 않게 됩니다 * fix: Seat Reservation reservation 바뀐 정보 반영하도록 save 사용했고, Detail확인하는 영역은 컨트롤러까지 분리했습니다 * style: seat ktlint seat폴더의 파일들에 ktlint 적용 * modify MakeFile * 공연 검색 기능 구현 * fromEntity parameter change: receive Entities -> receive DTOs * modify searchPerformance endpoint URL * chore: makefile OS 따라서 다르게 작동하게 수정 * 공연 1개 상세 정보 반환 * minor modify: change how null type is handled * 초기 데이터 수정, 공연이벤트는 일단 공연 기간의 첫날과 마지막날만 생성하였음 * modify DataInitializer, SeatIntegrationTest * add PerformanceDetail Uri * make PerformanceItegrationTest.kt * feat: 예매 동시성 동시성 처리 완료 동시성 처리는 잘 되지만, 테스트 코드 작성이 많이 까다로움 * feat: Get My Reservation Api 유저가 로그인한채로 본인의 예약 목록을 확인할 수 있는 기능 추가 * feat: Seat Get TestCode Get API가 정상작동하는지 테스트코드 추가 * modify SearchPerformanceResponse * check auth for create, delete performance * check auth for create, delete performanceEvent, performanceHall * add posterUri attr to BriefPerformanceDetail * minor change * feat: 예매목록 Brief로 주기 기존에 id만 주던 것을 Brief 데이터의 리스트를 주는 것으로 수정 signin을 할 때 User도 같이 반환하도록 수정 * fix: 로그아웃, 토큰 재발급 버그 수정, 엔드포인트 수정 * add UserIdentityNotFoundException * modify PerformanceDurtion * feat: 공연장 생성시 좌석도 함께 생성 * feat: seat 초기화 적용, 버그 수정 PerformanceHall이 생성될 때 Seat을 같이 생성, PerformanceEvent가 생성될 때 Reservation을 같이 생성하도록 변경 * feat: Find PerformanceEvent PerformanceId와 LocalDate로부터 PerformanceEventId를 반환하도록 추가 잘못된 PerformanceEventId로 빈좌석정보를 확인했을 때 에러 반환 * chore: .env 무시 * feat: Reinforce Simultaneous test code 동시에 여러 사람의 접근이 있어도 통과하는지 테스트 코드 추가 동시에 여러 사람이 서로 다른 좌석에 접근할 때 테스트 코드 추가 * feat: Review/Reply 연결 review와 reply 마무리 * comment: user 설명 추가 username과 password의 조건 설명 추가 * chore: 필요없는 코드 지우기 * feat: Sort Reviews and Replies GET을 통해 리뷰나 댓글을 조회할 때 최신순으로 반환한다 * style: add new line at EOF * hotfix: test 안되던 오류 수정 * fix: Change Reservation ReservationEntity 예매하면서 만들어지도록 변경 * fix: error 핸들링 SeatService에서 DataIntegrityViolationException을 핸들링하기 위해 saveAndFlush를 사용했다. 기존에는 save가 트랜잭션이 끝나고 완료되어서 오류가 핸들링이 안 되었었다 * fix: Seat Test * fix: test conflict resolve 테스트에서 같은 username을 쓰던 부분을 수정 * feat: Seat Api Test * fix: 예매 취소 POST -> DELETE * add: Review, Reply DTO Instant -> LocalDatTime * feat: Cursor Pagination Tools CursorSpecification은 조건 추가해수는 기능 CursorPageService는 cursor 포함해서 검색하는 기능 CursorEncoder는 cursor를 컨트롤러에서 인코딩/디코딩 할 수 있도록 하는 기능 * feat: CursorPageable 적용 * fix: modify CursorPageable 디폴트 값을 데이터 클래스가 생성될 때 채워지는 것으로 적용 * fix: Reply Test Instant 대신 LocalDateTime을 씀에 따라 테스트에서의 파싱도 변경 * feat: response hasNext와 nextCursor 반환 * fix: nextCursor 수정 원래 객체 자체를 인코딩해서 ".id",".id"같은 값이 인코딩되어 나왔습니다 get을 사용해서 객체의 값을 가져올 수 있도록 수정했습니다 * feat: Performance Pagination 완성 * feat: pagination 적용된 review 조회 v2로 만들었습니다 * fix: Review Cursor 기능 완성 * feat: Reply에도 Cursor 적용 * fix: 엔드포인트 분리 * feat: performance 엔드포인트 분리 v1과 v2로 나누어 선택할 수 있도록 * feat: permitAll for pagination * fix: test 코드 수정 * fix: test 수정 * feat: Performance Search & Get Review 페이지네이션 적용 완료 * feat: Exception 처리 * fix: 기존 테스트 코드 유지 * feat: Reply Pagination 완성 Performance, Review, Reply Pagination 구현 완료 * feat: Performance Date 추가 * feat: data 추가중 * feat: MUSICAL data 완료 * feat: PLAY 데이터 추가 * feat: CLASSIC 데이터 추가 * feat: CONCERT 데이터 추가 --------- Co-authored-by: Dohyeon Kim --- .../interpark/config/DataInitializer.kt | 596 +++++++++++++++++- .../interpark/pagination/CursorException.kt | 2 +- .../wafflestudio/interpark/PaginationTest.kt | 4 +- .../interpark/PerformanceIntegrationTest.kt | 2 +- 4 files changed, 590 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index 3db37db..1bbf5fa 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -75,6 +75,136 @@ class DataInitializer( address = "서울 종로구 세종대로", maxAudience = 100 ) + performanceHallService.createPerformanceHall( + name = "동덕여자대학교 공연예술센터 코튼홀", + address = "서울특별시 종로구 동숭길 134", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "대학로 자유극장", + address = "서울특별시 종로구 대학로12길", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "인터파크 서경스퀘어", + address = "서울 종로구 동숭길", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "샤롯데씨어터", + address = "서울특별시 송파구 잠실동", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "디큐브 링크아트센터", + address = "서울시 구로구 경인로 662 7층", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "예술의전당 CJ 토월극장", + address = " 서울특별시 서초구 서초동", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "인터파크 유니플렉스 1관", + address = "서울특별시 종로구 동숭동", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "인터파크 유니플렉스 2관", + address = "서울특별시 종로구 동숭동", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "콘텐츠박스", + address = "서울시 종로구 동숭동", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "지인시어터", + address = "서울특별시 종로구 동숭길 25", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "링크아트센터 벅스홀", + address = "서울특별시 종로구 대학로", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "라온아트홀", + address = "서울특별시 종로구 대학로", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "국립극장 달오름극장", + address = "서울 중구 장충단로", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "대학로 아트하우스", + address = "서울특별시 종로구 대학로10길", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "대학로 제나아트홀", + address = "서울특별시 종로구 대학로", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "예술의전당 자유소극장", + address = "서울특별시 서초구 서초동", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "KNN시어터", + address = "부산광역시 해운대구", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "용인포은아트홀", + address = "경기도 용인시 수지구 포은대로", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "롯데콘서트홀", + address = "서울특별시 송파구 올림픽로", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "국립정동극장", + address = "서울 중구 정동길", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "유니버셜아트센터", + address = "서울시 광진구 능동", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "서울랜드", + address = "경기도 과천시 광명로", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "KSPO DOME", + address = "서울특별시 송파구 올림픽로", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "인스파이어 아레나", + address = "인천광역시 중구 공항문화로", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "광주예술의전당 대극장", + address = "광주광역시 북구 북문대로", + maxAudience = 100 + ) + performanceHallService.createPerformanceHall( + name = "의정부예술의전당 대극장", + address = "경기도 의정부시 의정로 1", + maxAudience = 100 + ) // 2) Performance 데이터 넣기 performanceService.createPerformance( @@ -161,33 +291,285 @@ class DataInitializer( posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24017573_p.gif", backdropImageUri = "http://example.com/backdrop/mom.jpg" ) + performanceService.createPerformance( + title = "종의 기원", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24016611-01.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24016611_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "하트셉수트", + detail = "https://ticketimage.interpark.com/Play/ITM/Data/Modify/2025/1/2025013111034957.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25001185_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "그해 여름", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24017986-08.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24017986_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "명성황후", + detail = "https://ticketimage.interpark.com/P00041072025/01/20/762a8f19.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/P0/P0004107_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "알라딘", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24012498-18.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24012498_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "베르테르 25주년 공연", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24017198-07.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24017198_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "시라노", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24014885-18.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24014885_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "여신님이 보고 계셔", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24014618-03.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24014618_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "빨래", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24006709-30.jpg", + category = PerformanceCategory.MUSICAL, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24006709_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "쉬어매드니스", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24006928-20.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24006928_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "죽여주는 이야기", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/23008491-103.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/23/23008491_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "꽃의 비밀", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24018192-06.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24018192_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "한뼘사이", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/22014277-143.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/22/22014277_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "붉은 낙엽", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24016741-11.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24016741_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "사랑해 엄마", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24015841-19.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24015841_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "사내연애 보고서", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24008626-24.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24008626_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "바닷마을 다이어리", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24017992-12.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24017992_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "불편한 편의점", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24004660-31.jpg", + category = PerformanceCategory.PLAY, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24004660_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "지브리 OST 콘서트 : 디 오케스트라", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000133-03.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000133_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "라흐마니노프", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000715-01.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000715_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "코리안챔버오케스트라 창단 60주년 기념", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24018282-02.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24018282_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "토요키즈클래식", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000321-02.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000321_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "IBK기업은행과 함께하는 예술의전당 토요콘서트", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000463-02.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000463_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "지브리&디즈니 영화음악 FESTA", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000640-05.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000640_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "광대", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24018235-04.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24018235_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "백건우와 모차르트 〈Program Ⅱ〉", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/24017702-06.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24017702_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "피아노 파 드 되 - Dancing with Pierrot", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24018007-04.jpg", + category = PerformanceCategory.CLASSIC, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24018007_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "정동원棟동 이야기話화 3rd 전국투어 콘서트", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25001118-01.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25001118_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "2025 폴킴 소극장 콘서트", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000089-04.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000089_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "2025 World DJ Festival", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24010212-04.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24010212_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "j-hope Tour ‘HOPE ON THE STAGE’ in SEOUL", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000014-01.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000014_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "TAEYANG 2025 TOUR", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24018375-04.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000516_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "“TAK SHOW3” - 앙코르", + detail = "https://ticketimage.interpark.com/Play/image/etc/24/24018317-05.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/24/24018317_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "2025 윤하 앵콜 콘서트 〈GROWTH THEORY : Final Edition〉", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000432-02.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000432_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "2024-25 Theatre 이문세", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000934-01.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000934_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) + performanceService.createPerformance( + title = "김창옥 토크콘서트 시즌4", + detail = "https://ticketimage.interpark.com/Play/image/etc/25/25000115-01.jpg", + category = PerformanceCategory.CONCERT, + posterUri = "https://ticketimage.interpark.com/Play/image/large/25/25000115_p.gif", + backdropImageUri = "http://example.com/backdrop/mom.jpg" + ) // 3) Performance Event 데이터 넣기 val performanceEvents = listOf( Triple( "뮤지컬 지킬앤하이드", "블루스퀘어 신한카드홀", - generateDateRange("2024-11-29","2025-05-18","16:00:00","18:00:00") + generateDateRange("2025-02-05","2025-05-18","16:00:00","18:00:00") ), Triple( "마타하리", "LG아트센터 서울 SIGNATURE홀", - generateDateRange("2024-12-05", "2025-03-02", "16:00:00","18:00:00") + generateDateRange("2025-02-03", "2025-03-02", "16:00:00","18:00:00") ), Triple( "웃는남자", "예술의전당 오페라극장", - generateDateRange("2025-01-09", "2025-03-09", "16:00:00","18:00:00") + generateDateRange("2025-02-09", "2025-03-09", "16:00:00","18:00:00") ), Triple( "2025 기리보이 콘서트", "블루스퀘어 마스터카드홀", - generateDateRange("2025-02-01", "2025-02-02", "16:00:00","18:00:00") + generateDateRange("2025-02-10", "2025-02-02", "16:00:00","18:00:00") ), Triple( "2025 검정치마 단독공연", "올림픽공원 올림픽홀", - generateDateRange("2025-02-01", "2025-02-02", "16:00:00","18:00:00") + generateDateRange("2025-02-03", "2025-02-02", "16:00:00","18:00:00") ), Triple( "콜드플레이 내한공연", @@ -207,23 +589,217 @@ class DataInitializer( Triple( "발레의 별빛, 글로벌 발레스타 초청 갈라공연", "세종문화회관 대극장", - generateDateRange("2025-01-11", "2025-01-12", "16:00:00","18:00:00") + generateDateRange("2025-03-11", "2025-04-12", "16:00:00","18:00:00") ), Triple( "연극 애나엑스", "LG아트센터 서울 U+ 스테이지", - generateDateRange("2025-01-28", "2025-03-16", "16:00:00","18:00:00") + generateDateRange("2025-03-11", "2025-04-12", "16:00:00","18:00:00") ), Triple( "연극 타인의 삶", "LG아트센터 서울 U+ 스테이지", - generateDateRange("2025-01-28", "2025-03-16", "16:00:00","18:00:00") + generateDateRange("2025-02-28", "2025-03-16", "16:00:00","18:00:00") ), Triple( "세일즈맨의 죽음", "세종문화회관 M씨어터", - generateDateRange("2025-01-07", "2025-03-03", "16:00:00","18:00:00") - ) + generateDateRange("2025-02-07", "2025-03-03", "16:00:00","18:00:00") + ), + Triple( + "종의 기원", + "동덕여자대학교 공연예술센터 코튼홀", + generateDateRange("2025-02-04", "2025-05-03", "16:00:00","18:00:00") + ), + Triple( + "하트셉수트", + "대학로 자유극장", + generateDateRange("2025-02-04", "2025-06-01", "16:00:00","18:00:00") + ), + Triple( + "그해 여름", + "인터파크 서경스퀘어", + generateDateRange("2025-02-12", "2025-03-02", "16:00:00","18:00:00") + ), + Triple( + "명성황후", + "세종문화회관 대극장", + generateDateRange("2025-02-15", "2025-03-30", "16:00:00","18:00:00") + ), + Triple( + "알라딘", + "샤롯데씨어터", + generateDateRange("2025-03-01", "2025-06-22", "16:00:00","18:00:00") + ), + Triple( + "베르테르 25주년 공연", + "디큐브 링크아트센터", + generateDateRange("2025-02-15", "2025-02-16", "16:00:00","18:00:00") + ), + Triple( + "시라노", + "예술의전당 CJ 토월극장", + generateDateRange("2025-02-15", "2025-02-16", "16:00:00","18:00:00") + ), + Triple( + "여신님이 보고 계셔", + "인터파크 유니플렉스 1관", + listOf( + listOf("2025-02-07T16:00:00", "2025-02-07T18:00:00"), + listOf("2025-02-08T16:00:00", "2025-02-08T18:00:00"), + listOf("2025-02-09T16:00:00", "2025-02-09T18:00:00"), + listOf("2025-02-14T16:00:00", "2025-02-14T18:00:00"), + listOf("2025-02-15T16:00:00", "2025-02-15T18:00:00"), + listOf("2025-02-16T16:00:00", "2025-02-16T18:00:00"), + ) + ), + Triple( + "빨래", + "인터파크 유니플렉스 2관", + listOf( + listOf("2025-02-07T16:00:00", "2025-02-07T18:00:00"), + listOf("2025-02-08T16:00:00", "2025-02-08T18:00:00"), + listOf("2025-02-09T16:00:00", "2025-02-09T18:00:00"), + listOf("2025-02-14T16:00:00", "2025-02-14T18:00:00"), + listOf("2025-02-15T16:00:00", "2025-02-15T18:00:00"), + listOf("2025-02-16T16:00:00", "2025-02-16T18:00:00"), + ) + ), + Triple( + "쉬어매드니스", + "콘텐츠박스", + generateDateRange("2025-02-15", "2025-02-18", "16:00:00","18:00:00") + ), + Triple( + "죽여주는 이야기", + "지인시어터", + generateDateRange("2025-02-15", "2025-02-18", "16:00:00","18:00:00") + ), + Triple( + "꽃의 비밀", + "링크아트센터 벅스홀", + generateDateRange("2025-02-15", "2025-02-28", "16:00:00","18:00:00") + ), + Triple( + "한뼘사이", + "라온아트홀", + generateDateRange("2025-02-28", "2025-03-01", "16:00:00","18:00:00") + ), + Triple( + "붉은 낙엽", + "국립극장 달오름극장", + generateDateRange("2025-02-20", "2025-02-25", "16:00:00","18:00:00") + ), + Triple( + "사랑해 엄마", + "대학로 아트하우스", + generateDateRange("2025-02-20", "2025-02-25", "16:00:00","18:00:00") + ), + Triple( + "사내연애 보고서", + "대학로 제나아트홀", + generateDateRange("2025-02-20", "2025-02-25", "16:00:00","18:00:00") + ), + Triple( + "바닷마을 다이어리", + "예술의전당 자유소극장", + generateDateRange("2025-03-01", "2025-03-31", "16:00:00","18:00:00") + ), + Triple( + "불편한 편의점", + "KNN시어터", + generateDateRange("2025-02-03", "2025-02-28", "16:00:00","18:00:00") + ), + Triple( + "지브리 OST 콘서트 : 디 오케스트라", + "예술의전당 콘서트홀", + generateDateRange("2025-02-15", "2025-02-18", "16:00:00","18:00:00") + ), + Triple( + "라흐마니노프", + "예술의전당 콘서트홀", + generateDateRange("2025-02-10", "2025-02-10", "16:00:00","18:00:00") + ), + Triple( + "코리안챔버오케스트라 창단 60주년 기념", + "예술의전당 콘서트홀", + generateDateRange("2025-03-02", "2025-03-02", "16:00:00","18:00:00") + ), + Triple( + "토요키즈클래식", + "용인포은아트홀", + generateDateRange("2025-02-15", "2025-06-21", "16:00:00","18:00:00") + ), + Triple( + "IBK기업은행과 함께하는 예술의전당 토요콘서트", + "예술의전당 콘서트홀", + generateDateRange("2025-02-15", "2025-02-15", "16:00:00","18:00:00") + ), + Triple( + "지브리&디즈니 영화음악 FESTA", + "롯데콘서트홀", + generateDateRange("2025-03-05", "2025-03-05", "16:00:00","18:00:00") + ), + Triple( + "광대", + "국립정동극장", + generateDateRange("2025-02-12", "2025-02-21", "16:00:00","18:00:00") + ), + Triple( + "백건우와 모차르트 〈Program Ⅱ〉", + "예술의전당 콘서트홀", + generateDateRange("2025-03-10", "2025-03-10", "16:00:00","18:00:00") + ), + Triple( + "피아노 파 드 되 - Dancing with Pierrot", + "유니버셜아트센터", + generateDateRange("2025-02-24", "2025-03-07", "16:00:00","18:00:00") + ), + Triple( + "정동원棟동 이야기話화 3rd 전국투어 콘서트", + "올림픽공원 올림픽홀", + generateDateRange("2025-03-28", "2025-03-30", "16:00:00","18:00:00") + ), + Triple( + "2025 폴킴 소극장 콘서트", + "블루스퀘어 마스터카드홀", + generateDateRange("2025-02-08", "2025-02-16", "16:00:00","18:00:00") + ), + Triple( + "2025 World DJ Festival", + "서울랜드", + generateDateRange("2025-06-14", "2025-06-15", "16:00:00","18:00:00") + ), + Triple( + "j-hope Tour ‘HOPE ON THE STAGE’ in SEOUL", + "KSPO DOME", + generateDateRange("2025-02-28", "2025-03-02", "16:00:00","18:00:00") + ), + Triple( + "TAEYANG 2025 TOUR", + "인스파이어 아레나", + generateDateRange("2025-02-05", "2025-02-05", "16:00:00","18:00:00") + ), + Triple( + "“TAK SHOW3” - 앙코르", + "KSPO DOME", + generateDateRange("2025-02-22", "2025-02-23", "16:00:00","18:00:00") + ), + Triple( + "2025 윤하 앵콜 콘서트 〈GROWTH THEORY : Final Edition〉", + "KSPO DOME", + generateDateRange("2025-02-14", "2025-02-16", "16:00:00","18:00:00") + ), + Triple( + "2024-25 Theatre 이문세", + "광주예술의전당 대극장", + generateDateRange("2025-04-11", "2025-04-12", "16:00:00","18:00:00") + ), + Triple( + "김창옥 토크콘서트 시즌4", + "의정부예술의전당 대극장", + generateDateRange("2025-03-16", "2025-03-16", "16:00:00","18:00:00") + ), ) performanceEvents.forEach { (performanceTitle, hallName, eventTimes) -> diff --git a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt index ccda7e1..0a3dd6a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/pagination/CursorException.kt @@ -33,4 +33,4 @@ class CursorNotComparableException : CursorException( errorCode = 0, httpStatusCode = HttpStatus.BAD_REQUEST, msg = "Cursor not comparable", -) \ No newline at end of file +) diff --git a/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt index cb41ce1..6fa9829 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PaginationTest.kt @@ -94,7 +94,7 @@ constructor( @Test fun `공연 전체 조회 페이지네이션 테스트`() { var cursor: String? = null - val maxIteration = 4 + val maxIteration = 15 var iterations = 0 var totalItems = 0 @@ -137,7 +137,7 @@ constructor( @Test fun `공연 일부 조회 페이지네이션 테스트`() { var cursor: String? = null - val maxIteration = 4 + val maxIteration = 15 var iterations = 0 var totalItems = 0 diff --git a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt index 76e6b3e..de20399 100644 --- a/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/interpark/PerformanceIntegrationTest.kt @@ -149,7 +149,7 @@ constructor( .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().`is`(200)) .andExpect(jsonPath("$").isArray) // 응답이 배열인지 확인 - .andExpect(jsonPath("$.length()").value(3)) // 배열의 길이가 0인지 확인 + .andExpect(jsonPath("$.length()").value(12)) // 배열의 길이가 0인지 확인 } @Test From 8046618b413f1658fa88679756851b3dcfef8748 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sat, 1 Feb 2025 15:43:57 +0900 Subject: [PATCH 139/162] readme save --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 08f52c4..4438db8 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ > 주요 기능은 **사용자 인증/인가(JWT)**, **게시글 CRUD** 등입니다. > 와플티켓 안드로이드 앱의 백엔드 서버 역할을 합니다. --- +[와플 티켓 벡엔드 서버 API 문서](http://15.164.225.121/swagger-ui/index.html#/) ## 목차 From d1a9a6c8338feb4f03c052cf42dbbbf20bd76627 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sat, 1 Feb 2025 22:10:44 +0900 Subject: [PATCH 140/162] =?UTF-8?q?feat:=20readMe=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- EntityRelationDiagram.png | Bin 0 -> 461452 bytes README.md | 101 +++++++++++++++++++++++++++----------- 2 files changed, 72 insertions(+), 29 deletions(-) create mode 100644 EntityRelationDiagram.png diff --git a/EntityRelationDiagram.png b/EntityRelationDiagram.png new file mode 100644 index 0000000000000000000000000000000000000000..cd68501481655fe99e630a61d6d473941355942d GIT binary patch literal 461452 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%0D+y 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

IDtJUlHc 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

r6Uz_Xj^+ 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

-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

*nb~IEwh4^<4pyEAEfqkj!#Sd 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

94nN*?CAg`3R;nh`;=6s7h@ zO*vgr(Or*4i>b6NM#UP$9RE%+av8`tBGeQjy~{6i)n!hW(CbV}iHXHrgHGs_cY?AI zl^>NVWq5g69i@mN-WB$EySeSg7d4??VC~52!pi%EOk57EfIEOf>l3*uHBG_H3du@D z=IqiE&BO%!WokApLBiLhLD&lp_2BxJsi0%dt?|^-s$naW>F3ULNj*SQ$qqoNWGc%@SFI`gjBzI%8lkH ztoT+^CWAyoMBLc!`wm_*5u3}L8v*0^$a&dt(7+_N4!;ysL zZ>Kms7Dw3Jsu0*|{6I+nH}*5JaqO7&eL+;lct2cM32oLA>_heVy4DEUs~&BL=rKaX zE&ZCp%DZ<8ztJFfK~#jx*(4{N7|62->Vv7)CIs-SV`TBUM3OKYYYrGEpMjW0Xlk{$ z*Ajgj;rD{Yq%HSzQGUuh`$U8nTD&ad0$0k^)0A7Q_V@4*YR-p+Z0~i|eZ`uz6 zz6-4fH!m+99p#>pB{KkP#t7yJsG(ImkuA#1Fh3Y1MO9VtfaCY}wxO}$EV-h@i z&3x@3p2hfhEE?%11b9Vdww2Y~Jv>5hPQcclgDa*t_?UMh7Ws*+#tpzIc-^ z+=Q_CaB)_V=tsyOOW*LB=wJ?*1K~gh%a1SQlV3@OFUl^rl^$=$RYsj|ts90*0E#@5 zLPG%cfTJ{~5+2mkP&d<<>vNJTpeAQSOsHb#KXeF>#L6u$Ca5FKqw5&LM)@zP>^gCy zz*JgQO^rz*xV63A+}PO8&F#q)jB4Uf8gH(v`wHGyz~jeKu&>e5TG`shJO8{m`2Z`S z;faH$ECW*ZGJOu8c_PHw^-ra;{<>ynzK+}AOuy~ytZJi1_H$US?=J<2Dj>0Q>!Q*< zL-Ripqoo+g`DEO(m@D_%9OYhS<$nA2&oV@FD?siQ#1PFv?_w7Tu#Zc4`1`pza>nP~ z-3;h2!3(Em--o7)em!98;_Pc<-`Orewp3n8C8$@J1=KJ$u@S7>x^_V6rLclUHc1tn zzYFgVH*}%?>|(rv{Tx6!-@Si$_yW0&=MmN4-*0fQ6I4kP*u@BdB(ZO5cMj~T)V<7z zogj3~ll#5d50QsP$SIEFk0Eo6QN6BH<5#)vzbBl=<)QU@X0PTxOdM{gbv~)9Q{#E^ z^l4Af&m9=|15wk+=rwfM;=;lx2DtG`nm*9L!;b2z3gY;P|IX~VkBjQ?zbEF{BZ1gM+uijwzj|fGmZ|;?R`0 z1v+4$Rpnt`0kpO?HDj~0q;7bnHVvn+={^Y3AVdKo(#{Z`tDyz1X@D%iWd~~Y9`$x| zIJasEs=-@34m{-itczvZSVUQ(Rw@A|g1cL~8G{;0ffJ;ggM}awF}#z=l^MIhk;xvP zs)C7Ej6AJF12LhW<6#0us{-FdrKYPZw|SVKfa|Xr6achm(xfQbiNwisVz}|-elgnr z&xexTp0E?PD=mUP$}F<^z`7_xN;L_3e3}2mLVJV2;9yP;4oYKb=x(6{fME>w`H->L z?wCzb24GzO48((K9GeT~T_^)@4%*?C0WfL|VY8)0)vV!%QD=E4bR+nC4m87w@MRMUV`s6S;j`g8dd@M{4>c>M zwm~fr5)zVbQA3RPMiv?Yg?}{y<2hf!XU-|KfgtlnA~z}#WfkBcQ_;{4#hjR!c=Tbd zC*&SDJ$ZR2K(YCAl#NrahC0#1-F>hJ+~Y7=hKrN)kte_y@VxBFwki=KT!31)x3{OK zrYheUaKanL$;!7q$UyWW#?v0P#m2|0Ucxicj5=ju$GtbIAGSUa2agtDAb7y?t5tWI zuE=ShTl#NnFzS!C7T0A&lGH2)#l#Bqr~U+n&ufN;%0I+N!T27{6n#6A{6140B2XA8 zIY8ewR7nrGV}~7{Dq&;tUn5a7i6!TXwSg>qy#vN%kaHQzgV_9&S%Xs=b(vwwd+l2r zbRw1+#_KgMcXrQR(KfIW>(@-@qvlQL$7i2sJbsmxS1#YW(Hm>W;eg`9#%#cNCoOPH z+qCg@27A_noWWB6ZJ^@u!w!b2XNJv8O=miADbstSRIcB=83H%E&Z4oLtHNjYCIW%* zCZ(ZC)Ksr4>&W;^KoWCZoZ*ZtYd@(MpfZ9ORI5iS<(4%##{6TFQfv4XlpK%#dIU}p zqv^oKM9FvzzMYr|+3mAT<^Ss{RwLkn*`r-yjo!M~s5^m%EBNw)QtXc@ z$ma+W5~x(bXs~~LJj@WAgq1m?5y*&d$2m}sp|SM=4AFlls3R_amV$}+D|81S96_?x z$XRAX&w6+5IqEhRA1h1sHHBb|60Dze*J37{Bf%6zb)6y_F=Ns6P^HKUMnXX1b=HyR zg2^xMS4JM6L%S03rz1k`&R?YvmrJUJ%=W*1EWJNpolLVkllimax~)BUu8tnf%{{>x zU7pA+7|C6S1OJ=OSe=9#;XpxgLTK!BYa=?OTx5j9xnN2n+ud0-``%8%k55lsq+1DF z9UjdQ`v09-{33h9-_Szg#_NkUn`%QaY0vplb95rhfXMlK4320pc(!{i4{WX8>}h_i z$gkaSx&xAKg^*t}L@nx>XPtG>G`}j6dcC|{5PxOkD*b!@=||t+OZXyFr>#Y>Y4-N* z?T!!OdYc)B_})r^!PXyBgnFf+b%pP9c{-}}=Hu}Pc}+Eeo9zTl&)gQCT*Eu(4rl(| zIRiF_U(-?_mU_36NjK(wB%Q*Mg-$(h1CVSsmR?c-CrCgx7SM8RB6U!H0p>K6Ruo{NZglcwy^ z6+#KFZ~a>lfJa+S7D~8FM{?T{=X1P`=6UnQQZ$~>akVc(shWsEdVj&<)&;CJ8dZn4 z*Ua!F4lgG+7lsc+upL|xW>*j_%-bq^{(Nam@+Jmj>(&Yy3Hd@+osF_y!`rAl#5YE3 zq-Rl+%J_n^#j)EmggkADdVO6wvy@rfd z=*q#+^y>I|WG(YR3v*5JG#>PBEnwdBoskxmU^Yk&;O;zGwm&iRRD7eFa zHx}&rrGIliBf-`NN`))&;%mGigj~QLGkxhO&+s)OE@PxPBQYGRKrFta)AHAIOvG>oC7EN zq%+FF5apvIKdZ!qj+l~^rZ8S^iVxw>sO&qKpib=KX_%b-suGQ~4r~ zKh}5$>U*CKL&qb?Qse}4(R@v4j6h`4f${WUe?LQhp4kXdsMC zK>mnzsNODg&#l88&!vj7_Mz;Z@Os$F+o$@=^rN9PKvqRy$2K)>fzJdr7fhH@3O&u} z+MwuB-dw!3?Z(?VAn(0D^#>XpZ=!}4|F3U>wS9vlDcG5%`XbP4&W|7~N}0#dCsJzn zv3WI`&daZQDhItAr?(p4o!A&=8eHn;j#`fWlsh&TJj`GnbqX|4DOd41XPoDgGx#w| zLB($I;^C{f9-F@KlJ8Y&ED=Z7m6cI!lKzfN10FLs4Er$pr_BJfu?EPvmFQC2-!?l9iz(sdLdTh zlnzP`Ly0i;g?o;xG*tBV1jT8eb^~DpQX(4xu>GyjTs|j$Hny0+*c7r&P}079ts^pB z&tYL^nSrqH222?vOkJGB!=VR6yRQo+Qkd~q^c!CmC&l2je0+Um0>bll&sFfo3)*&b z*C9|brFejo0n5DVt6E`s`Qq>K*5pC6xqep@B0zeP9;7^(M7zEKDZR?@%&YDyJ*rUM z2E+UK4QWbJQdEIYyRnWK4nv3~%HjJ=O0Uvr@l)#wCbQ~doyjZ<9_D`fs}8&8o_b~K z&_7`-E9Tdwd}kJbPBPvW6MQYxs7#aM3j+U*4(WI<0hztODZbqocU*!_!JgOtGG5;M z))|P9Ti!oqn^phJ-9bD^-!URXjnC|TYFTyshQ8BLHN$hCa2V&Q0*VC;|sFz z9W)s|jFH2mle=dJZFYyr>FFa}D(Z(ne;!!sE1c4E@>fuZV=gA5{|uqZxm`GD0y2sM z#MWQt)i*(bfx>et@d%1B@X$x{ZNC8M3Mp^bkD`VgzP<7K{Nf^kkY4iucwmxO;+`ww zEoro3@hggFpV<&Cd#W>LJ=)iIMjeCwBMrl%;~EGLDW6y?>sCDpkcJsX4N3 zUq>=~)q~V^yp7`#hlN~p9^xswKlXNg%K!)N3NSeF!8Myd>om_4fQ&)gYbxO%hEoGaOhSVVhUTk5SES~E}7 zBlbOmP*@NsK#MnPKz&0Z2=r=W)2@ew{YMljT093A77O7ZseM3GwvDwecL z-rhB4q?=3Ii#Ric$=ep~Esi%&>fL>e>+GfNY67ko4fK?mzt(m-sj}AoIx`l~06(J% zma8vasfreg2Jss+Kh}$l%}uo&8#6O8h!_O%{mB`>p^)+uw0XVna_(?#$SQtIm-;Hv z17qso7+N_X;r`Yx+>%N1Cc%o50E$@cpsWT%iHSbeB{92h>S+ z)*w$9Zu4E$|9myUW=^W|aoLt3%$UeuDkzZdd!KF~A zA4Z!t#H9~ZQ}}t>qIxv?f;BoL4%VnK&naMAO18bGi@ zd`;f+gDMD&sbvf74INzd}vEN+Fx6_{)-ce#fD zughML*0$x=He79iFS2PU=ImbrwQw z_wx`2_A1B{8DCC=u-$o{Xtrl>GoA(}-NQk-^Al*epFo2X#7X8NK#b!B1vn-682DqY zUfefiX?=XE5(n%o7=&1_9xN#@()-(rgo6}xpPf`LxXxe96T^aneuH)_6?;-xSTGoL zGz2_iVq!u<_cxmS_@9KYxiLbP878OYGfJ!CEVd^}Iv-^|V`{66EcUOld2|-iX3_7y z+o^1|r)u~lSgS{J(DFI&m&V$K7ExY~``Qg93v+3WwLLhpb+?}q>89moOdOdLN<*?P zj0$9O|9$!A2W~%bh@6JSmbZ|@l1-?rJco-95V#ZSMh^(_c3zjhzTO0NPc%R85U=nx zf>c38?~lqRCYkav?%_;SWgHgns(KA{{!Q>bs5bWYQvW^=KHddCdGZ5?WLjCj8tB=Z ztpcgroG1e}e*6$>{3uSs#MB2P=@Q|d7wSCy8`XX5GXQEXyg4@-uxbcz;Tt)$Y%ckk z#yt0CaaP!whQf6jrHF!4OB6C7OnEj9eRlso@fzlfK^`0u-{PX~jRxsPl)I&G!t#|6Y9Ky}bt^E}1SB!I}FL-YbNrKHdESBh} z2GiLsg7PpPs9nR}K1B^0YSX)1oaeR3M2z39Po^BtmH)vz#?Hks;PX9Ev?bvyWr)OMl2V1BUlv^|kSvP@e4ex#mW1IjD? zXUg#g9E-rts)d0vNk?uj>u~oYjIZ)R+(K4fz%XuYY0b08I@#K2`VVqpuG@m%i^;!!iEo#Pqr|Mi}#IN zNL%4TndBjBrt{rKNv9Aq74v4A%uEbKq*vBw{k-1ZT3dDfhaC$$=q>NJPulK^eSehq zoDQ`F1Hq8^nD3g(Zl>EUL*%z7%%cRdXWJ0{{isyLdzKMdFoK9zgnD@lM_mU zM`PmeYZ|=M>V`xE0{|pK!tzjzG)tb_pXx%_N|aT%sW|>RH2C|PjJ!xM)r-N{#7~1o zNaouI>1y!=_PePmDUMVle1DPSl%~UesXso2yJ7&c>!t{iLCha8C7v~w*86wpR!&Bh zE;Egsy?4B-{5ij)qGQdBt(7%43O$Q}iz$zNi7s?FtHtC)X*jDTb@zSiK^i8*Jk!%} zD{Ucap8P1d841QiwUef1L@RZSD6+iWwU;=RuaxF{{ZjPo#@`5)PwBR?b|%A~Y!0%I z%6xJ2b*8TEX#H`tqjB=^;OEq3!oPmm;_(<`6-@_IX~k-aNwKYBjpThR1W*Jz+cBotEkF;DW)9Uv zJI~UiEj$(@9i6CTqeAgo&V*SB(z~n(XXB#dKKj4 zR`(W6*Rae4ezKW09XxCpi7eWB7}2>kQE$K}lC*i~Gq}%=IE9SLL0>~nK>;4|RN7M9 z!Y`L#9lV1F0{R%i{;mBpuniPP559Z@189oaI}iCjqC!VoR<_SA<~-G3an&ZZOcWo^4&H5vsiad%cp;Kqpj%As$0n0bT`a8VX@yojwFtsC!a6!Dn4w< z$vRd){Nx#OJSJH%yo_HbEO>(5_MuL@BTeQ~FipFG^}Eom-fm#YdLw@x|M*b)YZOIT zSpE!3&$5r9adj$dU)U^<;r_NvX5p zJnpOwfg@K-4@6X(h`ps-kgXH`7m5^Iw zaGP;8xIm2rJ&G(&JHZW3yQ6~x9T>yh?n(a`Oj1SRQ5}OdmCuB++}OIa2ycS;6R^SB zYETCN4M*$chuX09n%~naPqE$`^Etma7n(1(u1zpX z=Xmb!u>~z5%v-;*HdNgSjb{GhzVQKuAF_I#Q9oP%3NMhF-%|OJ!H~_dX%?Skc46Vf zOt1gfynMWNF87Cpvf5B^8Nm5g5ViAkPJ~DTT@E74{4#Gxkdq`}3s+QDS2tQciywk( z4%3)~?Z^GtviRm^0BDw>bdS!5v@xc=)62;d36Dl_;-cQRr;z9F@5Ajt#h`}RuT<33 zYViP;U=cAa0e6snMyKqn(;!a$J+Bm(_+x{sxOHkFkgsJpYn19~nrtPwbP>vCsdN&_ zA+~+t3T85fCC^wV-#nh$J4L5sy5)epa6$RKX)!l{8Uv%SP4TWN^E}zG8&0P5M%xv6 zSbBB?TC#8Dm+q7YN5q_;>#tdeoHuNJdHl9Nd5JgnBV(ISmvGX(g^9PX-1L~Wjw$qA z+H$-0u_km?qMR;cR-z*}ibVyvs$((o-&fPpi~&3plBbqPMoABvbpi&0EaERMO%oFQ{2qV^zy%jIMf>^`Dbe^2!x|~^P36oL zs;wajXlK0b5=GL`f5cty@3_59=vT;$9{_`Ne(daE)m9Y+z^)Id49~a$6v||o?9Ntl(ghiEkS#hL!<`+$#FSMCMqTd0?cGjl28%(FSnODXf<`UEUsVo zSZz;x1|m)xtoYW@-^gXP0vcL(mcJuu^a;dF-@ku<^@uH$69s-Kxy_vKRfZzM4pN+W zZuvkAL2TNtq*LF}Bsq!Bct4v}=MU|8n(R``79B0^IRD_eXxN#b346gqoNm|`n^0Rpqt67(z64*Z884dT5wFmI%{aa!>G(^LU^ zNwk`u*$={sGU!{hKS@4vg;@;xXRU59&Ye9*^3#LERh_o|O2;6e+Ypd!NT|~g9fgjqy7%a|A2*{4*J0Zw&spKR^2q_^ zi@P2;kvd{C!stP-?`Uh|7&88OX-FeY6Yqgd@GOB%74M5rG{)g|b8{e80=~Sd94Tof zxkB)u|BP^?Z+z|WhDO8zV~z+5S85nvctN;GB-cJ~={=w^%q@_VHgimpm*baly(TcU zRU8jTy>LCWxn{Rm1Cr7Wg2Je#$K-{E-kl@9SbpG01z>g;6 zV4GrzZI-7T9KwkJP=ncqBI+*rJ(BADPk)B{f2$q>kni(SQndIc9Toecpj#&4OVT~n zYb0Bd8Bd`|8j{8^&hRYMLt~ZIJ~J_-6`?K|O{FELq|^x1W{qVR7S0gHTM30iVQeac zMeVchKnJkDpu8vVC5_0$rW2yByadEnWb}=kx}_8IhS>LvGy6^V#fB^4Zf>e*(ASkl zH2kbOg@NcT-CJ`$E#~8i-Az=Qb<1GHkbcg6fkRq}&9OE5nk-tyOoTU>L5%s5HIWnL z!E)r2CUVvG;^JapU%tQZp?75CDq9A30%YA(0f2wq5V zu?vzg9*!2w`VbM=VWQcEk)G=n3{bRJtBmK?RDg4&yStbtC0bKf1az(Z*Cm96UTe3S ziR&Oa?phlpScAfE{LvQ8~VQ+V`l5C;-2O_(bRTrPu-?Fu$ zN+TEGVQBaYK0&+JJ6QmLXbeqEW&!*I0p+9Rolb#h%c<4XD{2j zHMg!9oq(gyv*@;0mYw2a1~5U;(b18Uqq`H};)+mqTI2kym{~}-vjQol|8|F%%$&JR zR{$dzBLGahQwNdMkdRT<4|IXh*W@M;#}hLdJ_F!v3RHRZ*V3qS5VSmvPoBQ`aQ%nD z$U9-S%)Xvr!o;BO&e1XDp7S5r@Bc!K=PE0(u~mRbUs3VeAU7zLCnmtqFmN4|S^2v? z-QC`2&XokPiqv!Mc_xcwz@FtzOWJDaRG!z8tHM=pyil>yfia7wPd4`TB1?D;U4>)ZGHclTm;*R)C82CT`FNXpU zCO-VK3k_u)i+Rcrs^bZ0H@<%t>~b3Tsd@jJXUXxatAkmp-&*Oqch}MxSVDkI?eoCh z08Ky&m4GZzgiyQDXCq*|Qz^)hVK%F2N)kDjvy&5pe4sZJ0Gtb}9Q=9>=es>2CuHpa zM!y^wO=m31%2MT<2B;f#auJX+_$GA8CR2-`L{ z0NkIw5(rkndZOd(T&3LrB2BOc01@UagfF21l2e#)M|&RzgBYZt+(d()wg@1_5xtK9 zVszCcpcj{v6o|zcZsz*2%j!>#eY^KBt`qf0=gfs1W$U|{D}$xUGL0CuIO*6LCS zKR*Rr1_q>zLp7|UBa(~2CSuUW|6#fWWhw-;u+Y%L7h0BeQJeVM4XsK)iOocOTSSQE zc*k?!v&8!tqmhi3Cm^Sk$ulV;hA<5AZjJYod?dfC@ggxMehH7}ewGym;!PaNSUc9* z!+AE@#+ruL@4rNNO_h<3`}aqp5@bO5o_ruwT~!qv7)bNF68arDxbU+t)i5>jMewm( zP>>9|H+(nv*7=0Lp530L5{2<-W*IM`fR~257SG}$G6ss`nW{azs|EM#-=P8yxf{-10Pg2ofN&&sp-Kt?Zo3E%UWyLa!X1*}`W-o0SN16w`V^p8)F9H0LD=)3B(A|oS}$)RoCdl`f6BxX9M%mL<` z!v@O`xPQlh9x7cN&B1+BN0vz4wN+7h3EQNM*dTrZr5LVn-hso^U<)1`dI_C676(^Qem#7h1TI#_A}*V5 zGMR$ct99RQ`B-MKDtHYJ)3n~bc3eDl6ApnS;w6@MeKW-|mS9c8pGKdTSX*>A!>phH6nl36S@eD|k zjD|?7aYpePKEYv=66#c)+XauyhlY0~k>`!whj>3Flk`8%3z19n%nxdB)$t>4Ys=1m z7hsl{bh9Gk;iHydw@^$AR{!KFJme3tmlEtoFxu-JJXTj11kV2Rg-$l-I43&X*a4(d zSRH@T(}sp#I9q@VHB)I=hN00U9EUDw4{iDB5I-Qz0n-TzX@Do>VZ01{Y4ChD zShu->9R<=_$jSGx%o`2RGQr`RRq2bET7TuWKo{cbe3J^OI7l6OLBz)+9%5^IZCUa6 zRyI&f&-7YJrF$c{ctyEmhH-d!hApgK^155d)kwMf*Spv2# zl&xd5^SRk)aaytx6gj)n8*ow2fA97ZAfY-B%8tCqrKm^<=)}h+C-Kc7a4o&?cL!S_ zj4%e~OeFU#>+5WV7VM8Er=SoI+_&TL;_M8+I9`MYeS5}4UQ&|E_X^8`<;I}E2QmoO zTv72`Mpl9}B=r1XRNx@l6VBrbTi66Pb8#mw58+As-0+QKTAxInn|h>JFwy zP?kg44!{e9n0ISyr!U}`0a7rtm2v3f&NvzOP&6uFNejbi$%38FUfc!!9@4a@?Z12Z^=+| z%$|Gvwz$c@Xt+yh>Brie6wI)|*+if@o5aoSP*-nrJJq|tjlkvxhLs4qpnY+97S?g} zC*1}cN@>tjf;JPxQXX)|@r3m6Lw@v}Wx0c>pxJR0+VZ)^TS`2(w~!peland3fX!K% znySXZRUCc;v{jT9G6`1S+@8#{&l5U$wf*S|3nPc0k6ZCAOE~LNlAOC)eZ7BVXU=ak zike?;_o;s}&>2KY%dzY}Wi}+e%g4U##;P;9F3=W{`7c%(!1n9N3n74o35yco_L4$P z|LgNC8yF10t361pvG}t=V6A|?HWVoV$IM>L0h%fl2^t$Y;oMqk6C^72DnhqKRl!;13T z46o_$=1@WpTfaBUXoS~0f`rK51Bc_BgMjG_)rKWdLM`m?@9(_pY*YNFHQ??1KdilX zI2U}|KW-NxgzW6h>{-grN=Bl{CbR4$m7SeUNJd5}$%<0;Ohv;kBQq&PrRDefblvy; zJm2Sc9N+Ih&vhL4ANO^4mp-5O`+T40Yn|Bx6okJl4Jpnb$zN>``dt^7VM4DE4}3%h$f~)iQ!)17?7Y|w=;%sgK>fRJf(S-lV@L=Q z;YI_#o-oU)uz#?|ai93ab-kv>FVFWo=I48`af@0`m`F4*^rYXr&U7>XK(9!Zn99$n zlZT#`r70_AOurU>D6$b3qEA!!)gAa(2)bOvv$PalPE zxXtbNkB#7tleph-7K0GAHuSkJ=g!;(GJgac+yJIxS$S1;wbR*I=ZPh}>z;%T6M3$I z5GA~!!eg(^*(c9T7xd%gQ;jgsitl3+e=;0inO5m&a&@J4sgN0629dIRcP7SskXVF{ zqyOUjI}yC?{ABZFA1*RtAO|>rogq&VoAVZcB9Qb=r3>L`R23D!6IdfBC%{mJ?s#6r*R#_3KC25i#~&6yz$@KLOuYrr?&rq|K(J zpl}Y_MdwTxzQBqZz4uM4o$OVrZ-^Q@3cl8w^MOZ}xM$U%-iv@D z;e-Akjzzr#0R(V238^rqrr|NMGkOBY@XN;ulhU_WVt-lmTR?ZGuY~^Ao-*fgHefvU z+Lw#rI}5i<;gKw6uP|zDL8@0ty&=EE`K2-q+*1fp6jXBroZ^B`tk`1(UNKX}+x|cL zK*SlsrbA=h?aZ@vgYrSzJhkj4%JzX@B?UwihAl+%54t6)zAab=9sWnVCHKKrq~Xs4 z&&Gr0c;{9=W1QPXLCqrB97sen{b#jnPul>_R@_Z7#iHcHKQ-HU{tIFZ z8FuZ0+Z_j2#jcesjOxN=>zBq&2g_sVZk>|Vvx}Wm`bc+ueW@A;TIHk<2r}{V>oQ|>BVIw{ zho*>ywVf}Z&(DcPBggrss%WCzT%FyPv0Hg5gU_1QZTF#1w{Q8)H2i&-DZ?*_uH!VFDlc-GyEQ|>z>&rr=sEr?ELQB#D^eo!|` za4l`<58yW+t&SY+X9SSP`DRS;TCvkW@v83or!sry$g@eO$tI3TG`X!*u(hFoF?cPz z#_&k)=_{6n?H?!lURyH$r~AT(6vGZUxf<;fcHXodqwd0c%6TAg1vmv%JoT8Tpv)gX zRLydLs}Kfo8q$12zG{_Lw-nLTgVl$K8D7(juD>MVSU&3gA++z>jcO`uBVco896PtI z^{6Iozbg>jR7vw`nP!8*?aM1>@R^Z`J9g$F4JPA-nv07I6v~f@UPSGJ$5nctfk-)N z5%t4E`k^i(ziK?8kQOK2pJF8kJcsRBB!TcFRxAQ`lV$1WSA$hufq~yol5-3KX6^Ic zBFvedt2?mmoN6bAvzBQP9PX=TwYq+ zpQny}gt_-k{tN+FfTss@M*^E%@3Dkw(Y70GQM8;?`_sj{d$cE2c}>X<9A|!-daYM? z%3SuRpWTt3H$6QuHbdx-G86A!qLl2wS5T|To;|I_Zyrpip`G_CvwO*``1imcT5;C% zQ*4e$xxTzgfGi`{$^I90-z}mdVKO>1p_6V1qTn|&1ssjTXI6ma1vwZGy5m`}y zWt8xKMn_8?I?dD=J@bPg`!^4fYAUk+Efzj4SL<7L$6rq36^EA*yGLlr;8}3hwQZw3 zgOxK85o8qGXZ`>V_Tw$G1%ir97n*}j!Z|)hU-4L>TAga}DLzm8PZ_{z)Wxy*Cfv<6|fB>|m(SKT7a7qiTwe>Dy2e!CH zC6V>HjSprc^=${bBr>efuxfC%ik9(ZUVwt2?tzl^L8>{pD<@iKfOH21X*vudJv!HiG{MDWEhf20bdeU!b zSj;Rgj^M5Q;w!$hbqWv0Koo&hJ2VtbXc<;`xsDHBPr7onG3Ac`#hCdmYqCVQs~d)H zU)BY+tU;4fJcVu(Er4D*Ar#YsWdIy=K_sgjR1JGSitO80@+hHH+n_~~l7_}zm^sIQ=4CDG3o0T5Pb}II74K;>GuBZfOpusw z8kDD4jNiau(s{~zfcV2*_Gi2eT>AsW&<5h|0|X816VM-|Y59t#hnJG9pjo1f`%c++ zX>R@`mpm`X{MFC{!EE-8!=nPD?#~)U+97*Wu$hJlcTR}Oryb5 zy~toa6=_$N4cWoZi3$1@!#K}@fM-bU5d6m?!WYd*L2@S1D7!LlRsWoV;(Fx=;Orr` z3AU^`4-%;)9v2CiFQGg_&?nL3k8?_whWB&wzG2=0R}lis6{kzX010C)Mj@cy>~q8p z!w=5xIk;a~_*Tp5fREb~nFY)jXp^aZRd$_obeus?0cJ~lj06&E!(2sU=5$1)zq%5Xr|Rq|>O$8}HSdCcIW5 zGn#A4fn!L`bs%dUXK{eDU-$ssP_1;ES8(2p$iYAU8-uQaQ!hE+(B8E&Cp+Fl;m96p_#u7pW@>48P_VY4?ulDnRUe=M zkBpmW@V!z%&%`8f!J&sj!=eLn3+Ugr-`Iz&u^l^#G++x*%HOkCa-!OKYHd;TM<(NQ z^;g*csMJwv!RV|?AC%;FpCdZbf^o>c#-t53^vIcNt$r8}#6xINmi7?J5*ELfU;jG- z)rmE<)*r-XNa!kbst&~L{QMB85`f@m#~akg^81kn2uls-4)lf7(1s$q(F5}*M#3w% zY1rlaED}evX7p(KeMpKSs<@$Yp-!sDf1$qsr}u7InIbxT#6BIfc}bZM1vZS7n8C_k z^?Om4AT~(%il+&{HCcZCh@Xqt8t!nR;Ys}rp)10_q%_fK#PbD#ofFUUJ>CX}q=yX+ z-h0~*2fxf~*Yo3OB9Ru05FW)f}=&66FTg0)5HinTL5o zFJ(s!;YAseJ34MwD?P6c22Q++ZtraZZ3Cp{OSpHKd zLH)vZcNtNnaH1W$3{;9;;mqp^(U6AEumye3;i5vzVBO#&K-=m3pfuPUlenvZaW28P zeFNY*GX36PY4hHK=&pz#NUDNeTY+;_@Gv(n+&= z*75U@?jCEMhmjb$AuF;a-^O3!MfuHF>2=jZmac1$+Gs~t^$Htr#< z{u*tu?_xhh@LsA{US1?{!nTk5f8YZ`#x5^11aaiz4;peRdJ)AHzLMh7X`5mZ0@2B>K*NXJ=giiclx? zz(1MSuP(R(bOM5EvRtSY#~s?=NZ@JKLJoafgwG+K$e-ZxRaI@qO})yYL}H9sV|GO^ zTN%BI@wN!cU0ARC#B)sk(h%4r1}(UMvJ%S?H1uTa&xK2C(e!*SZlkF=1^c)APWCuj z)VeVx8w0#>^=vJ~9cEy?h#-H^jCgl0tV|N;vdSAeXUp#0dl~>t&E5pv?b=6INjLRo zYi7{YxaI;5G0x4ln{>XbccRZ0uhx3@$7jkI4SysDJb(I>ubnTkMEwH~S@TO1Q&SKH zXLh8B(ncq%zjk2z$dV$ek+qmz#i6s#Z_E@!EqlAz$2qWvuO*Gl9<_d#o93)vS=s%< zDdjTqeYpaUQ%N>-j0BC@yBIzG8ip2^;PyV5l~;WJJ~RC|tLd=3+TfwB&7igSd*_Q7MhsrRXig%PQ`WXQ4BHsG2HgEJJ_@#co;?;`ZgoSn z0SWnUpIppgD;o(o-k|3q7@EF#;{2bZ{6pq%J}_UaDNttU?I}DqpyH%Jlx(%XY@U9T z(M>b?c`f&KMpy>8>5uQNCCYe`(VVpO@~2hi`x3p1$3YuxK%ed=ELuMazSg2H7EkO@ zXkiGky2j4;AZa4{)+N*i!4zbQTWUX$c9f(pbk*=V_0UgvKtUz?x2-WA{#IK_27@q` zgjJmxtsn3y;vQ?PAG!1PwL*dJf3wsb_v!F_ue8EiNd=S?NL^~QrS#a-H^+P;Yb9wA z;3{+ZVqu7An64x|jL8LNdKE^pm+1fG*u+j;m$W1K@)=t?wjT~Cj}t~AbS(N#JzwiacTI|K1jYq3Wgex_i_si5R=Yi$=5miWPftZ% z+ThbVD;zScir+n~_FtspYma<2kffze_g3w&WWa(|mh7Bb7wB_~jGf&<79R`#h?rB* z^EY>OUHKDP)h2y7fi1GwZfE`Fc;YrZKs+}bWg4C_gJLFVDE>V?{@3f$+1`HVWNmB` zSt89{WvF(h;yu`m;H_#3%Z;0%VntsCQZM4U+FXpc!GuJYdG~?!yyhz&B`HgIseuIB zy6meB>0Z^<3%}4{l5rsiUehY4r_r4CZTO27Abp-k^6j`aaM4`Xa=?l+W%3ptuD=2SyH2aq&~2 z2NT#8@<-OmJ@xteLvWk!rlX-E+r)QY1Y_vX3P!u-`IeSUl7ch(ncaaZi$bxyH) zSES9F4&G_JRkzzVWS3*lCt}{xfaB$siXBW&;&GbRymxyaGHsVK`??JY>;e4B+Vp9CvuN8h(Q*7>mJzj;1@-OSyB z;sB|3)9?O4ey^PYC?9psb!FqksW+BGhZ8winVIF`Fap9L968_VKXr|5JojBE}F|)fY8G^{7yN zl+bJ7L3j8tQEX&wO?$5}$4e$t!scM$3OA$LKDEnlaUJe~n??5@D<4aqeVsvmB`Roc z_FU~x((U4bhabD^ANaB4w#{POY{C9p-#lV}iB#SBzDDbIc5vvmsrWc4m*JnA7fn5R zG%pPV_q;DtrBd2|>#_aPne`L65H!udzyvLh09ts5VaBku6c0l5?u)&O(qC=wN;I@m z!)hya71OZ~vt^hK0o}<8&8;MRuDPy=)Him)X&ycWrH@Q^(@Z4wGwluTU&PR0*%Z8UTn+`7JwW2X^LXY$W6XSq|z!!Jj z3h)$_KMm`O6T@S7ISE8a)QcWGEafJL>u21e&>+}uW$inDJuNL_V-NRC@bTnQ$MKY^-B5u+{(C@W`A7ilNJi_x|!snD{w)AAhOfb$gxzLfGt?j_)7&|Zih zzmtDAo>%cboQd8d2M=1CWPS6V|9)X~1xVqESv_yTpfb{DpUig%?S31|xmZ7&o7YwYFrT0R0V9#O4r6FX!5q|SW7;*n?4uTs5BXNrn~4rhot zF)eTSZ{)@m{U}P`Z)eoKRZ;T5jS5=S11W+LEMepYR|2OR7yYy~t94)) zMCx4p{canbd~KSb7HK?9eK;j;i43R@0!jEdJm1$Zb|A^@*{UN|_Zb7F7xzQA zz2pOq`)`>CD3={NDZCcIx4(V|>=uu1e-pP|_7W=-cL=2|-jQ#=E}Sa?OR06d>P~wW z_?u5)*nm?Aeo9H#C>|9<8UjA$V?b+oHuAUf-$R&cB_}H@dy)qKrNq`fMbv=$2O8Pk zbaZqUaj##$hTsPbjk2QRunrlWOxgZT9@mmZnlLJ!3oCA~o>?xD4yr(q2E|h}ub7I6 zNNjYEU#d|BW*85bLv~u>DWV?ON&Fq!GEh!@+VPTxs}2Q;nl!)%O7u?<%lf>zZAL~< zCOoc`8Kp}IomZ+swsA+H!bP{LOwsga859yuL_|FPICysqQ)h1{$#~PFNBe1LYcrlH z*u7p;6#VvL=e%HoDa9+nLrvu{O--aHCr^F;d^E`)zH)mhfJ?A-y^_OJ#d92qwxS0G z>ZVhJz?7`dflG9K2>>ZRv`3kdI>^PNdbY#kZd6_cvrIT3s8R) z_)`ZDg&aY-rd-3;lfD_JgwlR(ySnYo_gAtSMMOlH#ZNl&kMhBW_^50v<68f><*{#Q zVFv^iPjMXjBKgg77bies@;|oYq?XU`yFs4Cs)M=8um5a_ zT<)Ywy=1mL_or?&ueRu9i%6frpZyn)s8sN7)y7^e6)SdBN&a@fpsIAnWm_zk{EyPf?mAEX3k#mNYoJ*ZL&3IY$i``nQApM~ zk8VT}qHY=bubY0(n52zSL!&{$JRJw**PC?wJR~}o#Cw?Ze!V?6F%XWETy;X+-~yT- z7zXt-#LlDZeufP)&yKK_Vx)29=$3EFp3q9uRaZaouoLaxHUV!vuk|fA9!G0z0F<^+R(G>i zd|JhFW%QYafaU28mcrE}`)##48DVE{aZU++-(M@iA=h-`(VJ#=w~oHC50NL2iEbby zkL+B+UqS`RGxna;ITfd~`sdcgv@M~W+KKD;iac0g!5Yxp^*GOCkJDyim+g*0Kz{li z^7>fPCFRZUp43tak}$V~rvZwE!+3n#kdC`e0fy>}Tfe{QW3M3T;QI%AW_MQqPbt=wRmEKbGR8#dfWX()9RVOj{{E8YXa5yhN zl_(Un*2wckk}hyJW|$_Ko3OA22IVSzq$?+go)LA4obqDlkD1wJSYcZ58ZEx4?bC_%neg7tIV8IW|SQS^%Uh{;lOTX3I;!^hOrn>C+MZ~OcWOudYp{ZrMiv)ET1;xwh1VJhnQ zbAK4a@~+&*yVj+)L&&nAJN@j8TTW8Cob=eQqstnU0W_DA57_dAW{};#%hKlx&OW?S z4#_BH6}XPqU{ONM4+>;LyK4D8pNsoYFsmkMxfC}~OlGe(0hW-tc$wl-uH|)vTXFcm z?@WlG2Dm3_@`NEMsR0@Wkj3rX{pM^}0^Wr2Ig&{^Bh|jQ` z)aoB8oYAvbhg1i|lh0oDct}n`I{@8LWZ$#r#o4KYN&*sYC65;E>!p-*`6DODmr8s$ zPtmbF46l!wlttY%oH0*I-P3Wl)On;iVKAh8SF~C+`-Q+IAf+E{{??0s- zK|~Y7olDi*`L`d8c_cXmwGGSFBlICH(zw{sNB?@_!lMQy$bTo8-J@ygzV<4M!J8T4 z!;+s?W5UWGhEAU#Pn7ujLyYpBvap(IzwYlY=O0a{sx{P)#Hc@eAx9fye);t3#)&Fe z-~?4Fx);K=R!08I1Xm~6@aIEFA5{E|>h;Ks3*)Yy|1C~xe*^lbq$Jz^pZnZyYHVMv zbRWDEYS8O@7WxwMBwd8WA__~3klEX>GhAZ8hMUim-E5kAWwSKyB)jO2{n3iHBmm$+ zb!D<>+H&?VX|Dp~fXcO7t6`5&&4GSTot_f<<9KAa`^Lo|99E{Mt7-4=fF!Hy1<&@RTSzCK;E_Zvk*>F#=sqmJNc)9_JosKTl8Y@WHthKBb7Xmui0H@tTV# ze#FrdDmp8PAKhGNa`&j&ZF|jr8z{f3UQoO}Y^Yuh=oHwW62>h3iaP4QSrm!XhTAPiP#YlH*R3yE9ZqxVVOq;hPxoJ6Fm@4Yp{8deM=^k2 zIVKZmxBKU@^XJeP)vko4pi+x+$p7jUy;tKtlz5bKbpNfp&Ch%lb}5%{6G1YY!T2vf zf*es~O^sj{5cT4|-L)fZIE5vp`N^%FN_7P1WQd(?o=B}275*-FZAfTwQhw*Ttsf%U+#v%@@Z|qywf^ZX$TnB-08rk}&3mv*2sNPpH?#C_ zy7ew6{*j$F@8t^{Y;<47pOJ0T8Y^qksa7zL3YOgXOTewt34fLd3MxT z0?wSLXQ-z)TJP;(ZT${R?`3hbRtTZ~Wxzs5tL)fKr_&9b4=WY2Z+pt|iQVz3jocih z#xDv0F|J{EP_fu9DJ7w?}DZ;*6gD*UuA&5HGY3 zKIE#|p=yhe1Aj8GP2w~(R-RDf^sei3DN^(41TZ9Bq?lpaQ`o38y}wqn#J$>7I(6uU zAS=twl*3d$#}ZU-<^`kDLEcYAf=wLW~9uwL=nwQIZ$on2iH z)Tjf@)*;tm=Pz?4lAg|HBPO(?|2|X@Kh~v#qZMl$(#}{JBS@-0CaI*s0Q-JvNwg(1 zCub~#nAEye@IPq+07G^wy7kn?fJS#;`uAs0Ofbq2^tK@Uu$W^GYvXSmYO1VUfv6Sz zvNiH|%vi52GY%$OIZ0iBgAQzzre?eT*&p(L3;hUhhZm;8^1<_8xt?vSi{W%r}<8K4jt!YUZ#s=x;#neXUuF4br2%J}#=TshE=5mc(qr5?=x z{nXrEC1e%;59`zaaDmvQfIYJWFYE)H5i!!x7`;B`1K42+hz6QzM3{U*_7u7$bMqlc zc%bV7*9I%aQEPhlA@q1_U(h)e78PMrVAI)&C-}8ye!>k96w%vo12&MHch%2Y37`1rjm;~!vXn-$ zu5hN$=zXSZQxqbOb{No5a?X#@i9KXbvN!UcqwV&Um`4DR3~z@ zBt3KV1O0XLV?(DuuOCvF>yMsMMk*i_(EqsCWP&TNfrjiBK6maLmI+X>T@Qq;>xa2D zXI@4|9Yo0xsiHMbBF-t`rU-FOfU*f1>fDZ+O6J)94a_QwZ_0PSAN`kUt<{hqK!?f# zVqQ0wugipZ$$yo||F?PSx5+m8(waQxD|d-z&g<8s>0-f&k(s`vlqK+UP9WP$f4we9}U1wN@y;M zR81$k+j|h3K^l#99^der1Isc%{84nYkhpj;Ytzq9`;)B@JFK&WITm(Ph94c-MlVc|v48WY$!kArja#!T#RDVUA|B$t>bj? zriclws`Sk4)ZXGc75F`ott)cK$wlQwMn<9l=-IX>zqkIFOWM}p{q$`Fj3B#0bveqcY@WsPk(Z@`%nn=`SAJ94 zTAM8$Rr(pQ97Qy98Fu-s2fB_VrI)TX>lScn^V&E~iD%{S=DLN+_%6J;q zT;Ul#8_rWf=%l$NB`dKZ44=WD#nKxv>orei%etm`NLK>(1g_Zgx-ry)bAJHJ^l%~6?%c#)&IjX*TE*GRM9HD@9js?e)y38Z+DIG zChVFj6BrsK|7%q|?(-J!Gkj5qO@tyEnLDr{k!(c4HM}IjYYUMEzHg?4U37HKJ3o#+_zan)y1F{53&<+hBq&f* z69knJ#3YO_tELnFae~(p-d%|7Y(YE2J5dk?mN!Qax3|Kt3r7S*MgXcm^u*8~KMc_W zJ@pDZzDl6bfzib`2K)>*0N~FS!n#jc*%~JO3f^#Oi}<7zli+lwJ5>Qlb*(<0IhtUj zi=u{yhmxt<;)uSh{Hv8JlkBuQ4{Gb^;x~p&%VP_DA zPb_g|!vi9w2_h{9tfRx+84H?W7xu5*Eo~TPAjOVo!Lft)?c~hU4keS0W=v zK2IMf1r+gX1>nBNNYyONW60%1{m{O|b{A1%sI%zrQh1llAeP-knf&?lr|(zH8VG3s zYlOLd6hJ<{!E3p>9)b;P=keqMf+3{4++{|DK;a+!BzS|NmlT5hdZ?{rRCm$4_wQ(MOC(P6xG zy`yHG+qYRg?Kjg(Vv?C;=oA)lHIhJ&K57<93KS`7=c%I)q=_c}+WbW8yb((kWr^nf zX5hu1bVi)u5A9sguatQ~`H7#U0e)a?d&`7-94I#{=`VqI4Zp1ZYTNOZbaW}VA0^xG zecCfNy_QxoQ~Gi&$s6nCRr@@RB4N7Ido>?3wF^&sM)F%8;dfPPEdDC-^53B~xIA0M zm?Q1JKxZw}icY7qf3lOLa|GBR^4Jj(u#TQVS63H!_QK%ZoTjGB*lr5B4r~A{yLX>9 zxpt}$=7&89mTZOw8|Cp5+Fa+9CD`C-Ih8eibz^Vbm_SblI#5mj2{iH0_@J3Vq;PlD z;UXX6($Q?pEiMy(|0+W6<*OJ0=ldaEhEUOhSnR{}(>pXBiCt4}i0M21Ug?y8c2@w`XqD;%@s@;RD z=Ld{#*aFSbmrvs-k>Bl^14inZOE}i=MMZ%lJxTnptOWepk2?rQ6;O07jsQm95wv-w zm`R7A?1CE$je{gVzavl}@K5j&$=>SQH;_Nm`vP?GJVfG~3E-0SbaYm}e$A7#Q}^@p zLwa;jP>{dBT!OrFUlu;S7YIj1?+2R^hO09MKBs^}D(N;Mh6sI|7OIHr7)l7C1z!*Q z$J-?%EsX)h-rgP{xa($3O%0@?z7A6US_o7i}@q-?*eKPciQG_1cUpWMfvV51M|9ca@YzbJ+IbCI0Y5ELy~FtDQx2 zdql7%+wz+y&Ltsc=Ztr*u0D#_Ohi8P+{cdwfvO%~vQMh3KS%PS(F=?SDva8R90cLL zG+hE6D+BM^hG%VPTtah_)}6s4Uuj=4qs7|aQSRn`a#IWH?EMQzc&PpsrQ{i9m}sG0 z#>!G8rYi_FfN>tR6|w9BX=`}XYv!4wl6ZSg3;6O{%_hIE|*VsKsd>G>l+{^|4Q z2O+frKZxxfE;sNXV2LAIS4e^{UBV-SJpA)$ooliE8Q2wQ8mP{W9^7;_akK?|9$yvHyg<4TzF@$3?kSb|Wuk>`4p&vY5GYzGGiVJ(49-f6^E5Mmsx zM!UN9Dk=s-CsI{S%xU+KWGG`5Q7@fW%DPF+NF}#j86C2Yp?1?LOGsB9nz^HTHQjP0n+SpRQe60U5cMoCF^L=ODG{eb?@dIxQ z9*$Q9m=jeXPQ;4o&Tv;deIUSdA3@4W-G!!rlM2lBhp`9uj-@0g6DDIOY**sAjNOSQ z+~$m3Tr61_VsPF=KmI~QhG2?ly3sO99I%oz<43D8EC2N^%f;-W_{tvnqdngE*X0Z1 z<+|al?~iQgJw{d9^`4qTF-dA|>dGDlZ@Ta#m*Zc_TNu?w@P6lL8w5Mq+d11NO;a^c zB{(on8D^<&zg17;W0IT8U?QbtG>%xlqq^rc9?!Ii38k7imP|t2#M;`&??ZWs&mk%4 znQRl|dE_Ge!77c>%s0t*f4}HgYV&<4%H&!To zJ4tM`0hLfJ6|GQWnM;^y{6LmVJnR1JhA(|@9F)l08d<7u@Rbqewml{B-P6tdO~vEm zW_N2V9tAMn75H9NRW&Uk{yJx!gs167%vs?HR-pqs+xvfA&?mmMhnzI`T(ZK4rGW=7 zhm9*#NVOV7WLuJaeqVj(9n5^3HIq}~z+5IbZTq>dg1vo$g}&b2u9s$7@itO&br;?E zK(yi<%^pKZ*|16-lBzn5)*Cj*hwD@|{EzTg_6VEStR}pN**45=Xjjk8{ z86E)Ghg1$6pcYw5bxH7H!nPwU%eMQ7m%wjjQcX;dVV6AfD#2|(8Hi-D zhh{j%GHA=r!pYGwNd`+gg@t3R9r3@PcicIP!`hdt^s1#Lluf7QX(4I<9yE~;JVsKm zRKeIdv?wxF5VlMV%jhJrek$dqnNix(Q%a5AjjfHu_tk<1S(Ak=gM-X`9Zi6TaZk|5 zbm>3}=!&TU0((hQV#l2pipnG{CJABv+RlUkO!u*)sk z7esp&DMN!6nI_Gi%fId${G_&vC*SCvr&Yemcnx#mOtVuzkOWY;gXJc)x_3L|2mWmQr!-nbelLkqty_D43iM%+sivgn4-K%bt z=AMbH^)&hB)TK|4$Fhs~(#pm5|F|2#Uqh?iLbS}X@X5E4!u`?rf!og?@_8Cn=&&eV zWs}qUHTP$mRztwHA9S;NlZQhTeCG`A5ZQ4FHe;1%lm1qhDj{5CXlVC^f_sD%EP==! zZ4*;JghorOlCP$xGX?mDQi#p5EMHhggZ?~QPM_g^{LP%~Z2Qv!Nps9wgc$K^?0q~+ z1Itw*V;w_*u_F>SbW*5ahtCvqg`H=h%tHWxp0LP(0uA@O5}IG46xkj^k-Wd19YUW( z2VhZSTN3?j;X%f`_iW$%+}Wm=CS*gd`ylxM-n(^y@j6WEuae6`Te>&s?jA}qoCStc ze)~3*P}WGnOP_SXNnb$61WeqI=dr*k60L?OxrC2e=0k?K<-&~Lg>f0}<1p7CB0l;I ziu%bStDcPHWaHz z!4}yyD|LQ0L~pd`pD}E(-?2RzvkI2N>79>2zV<4;IM19LzPB)YHc43nHb*#>#~%8N zI^<^G-~IYpQc^$ftOyV3JqJb!$rsV5<3^^l5xmAdXK*jny)7*y zg>)hBz|oJJ^|$2u8Ips2mt_w>N-K{E7`=I?;7>vw_3=Nv!JBh!^{VEo@sl~DQ|97z zx8-W|8)ADMTk4Ikc-&P2(nXAC4V@cq6G$mI7u zfn4a^P14GFBq_*rqSbE>dE4fIhg|J3ir{Lh-La~_!X5L$dopiiyFdQSCuY<;cduoJ za?_tYH+*ST-ert^l;?HlgE39^v?b(XC;7q)0DpPj6tzoQN4@G(op;}@PpgI;@k==S zEOPbne0ZVNBhrh}K4vaCK4uFdjS*??JBeP12nfuA(1WCcMVO=d6x#~j!`wJ?6onXT zod%@90&M}B|rpd z(IMS1ngU*+aVKIM*PcC>VQ7P3&&I}PM1i$52wIYM&4>ezb0 zEmUFiI~D~B5@^+)JUOVSSg$mDJ3P#>Xs)sGfJW;%^ykln&8PS08U9R8ku_o=63q@koRagO|RK;fiK&~M^CSm)3l5g zkRSnBJ!fv9T(x@^ibF}2HBF6z#}@q}>OM0DKW87*$oJFtGO@QOC4XKQSo=}i`I~2} z=Wo$ly}+YJT;~)VLz5nSYWLnmh8+uJ6`4k}@*8UUD3Lj(w-! z14NtWn~wugjJLgGdM1@#S-qh*u~}W_aXBZ!=xXLUZ**eG&8kGS+4S8&|HzNAd@FC2 z#RuzKfFz=eJd7$XN(!wMuUuVqO8D$gAxruqCs~KuHu&>qzR=L8kV31Ubd0m3GN_^o z#81&-akuq7exrNL*m7q~3|&|_9sJ509Gn|tQE49_yit`N^v8HMYkfaOI8Ik42 z%UcIqdEAQrynq)Tba^A&(Hn@A0E~!rX6_=`!RfbcK0{u}{54KJHi6)~a~o};vLriu z#W2Q={*wDlwiumuj^+~x$D$@y8*x;A%%h+#YAx@3;+p9r5J2gqzG8aW6?N0m7_R1-y_vR*tR>!UiEns)vI#wL!`Ps-Kqm3o@z{}dX6Q&Fn7 zKl9Ss_v#j?-@S2!OU@1tq5E(@hu8e#c3ctGETP`o3n6x`qRlkb{qW4j%lMNs&i?Qz z95Sg8ukh)R5`h z6N@Xz$)$G`0M$l=F36oNbK20{xI#7`GB?;t6f>$GYOVZATf$&~&=@B^#=<&6!f$4# z%>DjLgh&2ih7aeRaXVf7(tj#N3#d@hP0n7c!3&TvzX9`eu*UAH1A}5eMxEG0_f2c6LH$ida7& ziFl!*snM_Z0x1=xhDqpAyQb^e~OBo1?G= z4Q-#IC3-Y8diCD#DFVnFrfhl49HXyKs+mw;EuzTgNl#5+Got&The7uhIZ?`@c*F;xGlloQ2{Ql}a*HT0Fmzh?nVQT7=-RDCBF4d^k z5i>mfT^GQ;F*e#WVAWWBU4Z7@>%?o!VP;jtBignh0XO9qXU^;Y4Awpa!)&SNU~PC> zxktBFY-Z-}hpKsjoxs$5a%n$k+^l*Y{m74K(hU7#r-lCID{^yxVNrrV&ur|y#Q8eRhbt>ytAk{UU%oq&9gMorur}1u z5h+M-$NEwgvp3}aEk$i{OANi~RkGAuTNwNlBNP*Ud=`D7+rV`&nB}knqO1YjksXX) z0HvE&c>Q(%Y#Jd~Z@V_;?%?3yB#}?D2W9RGtjtKT>SX4}1e~<6=q}0a&KN57G>KS9 zCnDfD%Cm0pu1hhV3fny;K`SHCu^;>Ac|Db>?@LNVeqJuMLxY|;dT^0(K7-7ge~tV6 z2<2#t6IqZ@v|o+J9@VU>@<;Rwl#Sc%!fLLub;o?UYO?)zS;S$Zou{H7aI5h07Gn9S zzL0t2?oPovCVfYi^Q#i@#5(49 zul3@~Q|CW>P!H*kp}JrHg%vD&TMHF>Kkf3PRzAQNZTyR{Qh|m?wRK-jylqd+F=Lfc zo-w!}qhGV^k9ZL;STP`%e6RhuQ08cq>!al&2@@kxW2IB%OhpH|Mqdxo622`;!n9ei z8@sMk;j;%y2B=CpCo3mM=I#5$m9wA(=OUUuZ}~V78<2mcE{bDsX^i3&5ff{-C*T$9 zb7@OPozFF7@yk3QSR^o;)oM2w zz6zLaLM!1}BBkh6AB5A}IW;-4wRvN!fMGrQUeS%bac^|9FDa!yu&`ILmav;tn6xd8 zemXSl+`x6`B8i5Y*kBG@M9p-@%_2dmzSh%^i@j?d12x8YGyJr3*Mp^Hs9XvHnH~Y=0{Ln{8~JES)E>Sn_^Yj&fToysXAMwS0?F>nVA*%4MorXjtWvg&Ko{f_|V8SRjhml#R?_wO`o&BJWJp^n&Dpv;*-~%$MI@ zq-ZP_8afl=XN=oM`A;z%dP4hm4yjFZN7U!1SCA;9Q3h2Jfdv!^>RaDT@(DP9d5H?sIl9!jK(hzy@Fv;h- ztSf`J-jO3m9+*^sq9WuHvTdr7zL>syYfovW(Grl;`e)x?Y^MuNG896LR>aEri;QaY znh{46A4d9ISM;_AhYv=Naxfan#C(f8*QtViOuAqAgSw{Y;9;2>yiDITOr(n#3J zFyhrTu)l?^kUMC$z>i$8>XR`l0Av%?qjhr^^}M(a!R2PT<-FfdfS+{?N>nqV4O zXI8R~|D?9}@XrW!GEGfOu(J6(^PPeu-iC0;t*|clv(mY0XplO^JcFfd!PR98ZPz-7 zy=V~q+xX<@my3a#7G)dw#`m_c{U+X!vZY}gJBUs()hMl8eOC}_IG4uae619a!QIy!Wud`-WSZk)feD8|oE=3sl~%yW*2Yn0T~X{l)mLPI=k zhjryQ=@|}RX&M&sNiigqoC?$em~s90X4%hvirLuE`6KYcWo5g{x7t-4W@K@Gx5H*0 z_#P4^+9W}b^mlergvZKal}709$a7(yg5;DA{FR@UWNpv5ySvB5#Q`@>=0DD{^G;Qj z$?=9lHfn?j3tw$BZQQ_WaN>YVRha;t-QEcpyYbj6jw59(GzU1 zOAB*F@)RQ%3@lH~DsCq2%NUcqPTQlJ&-kFenT@9Z?`Fym`fB%|*nhXV@bL4aao(I? zX(Bi7C(SF2-m;qE(W>ZKxlIv6(XrB0%sJvZ%%$NM>!W4!{noFzr`_Ux2d1^MT$Ta- zAq`gbxntbbtEU^=J36LzO%mho^@x~b{qe)3h2#S+WL=yJLqkKEqbrguehZ10w9LQ> z%w{K}4#vrZTHZST&mG&T_76Vy)&KAFCUMh>QgkH_aQ#XJ;4rU(hUV4IQrr>Ga9k@a z$KxFaXEyb9(lax!?25L0-lS;~ z7_Uhe=-sXcOm&%|*X-bClka?DE|GTs6znnXM30huW|@+>s#?9T52~>5m^=jUZ^REm zjvDFqfbEI!i&1J(O6sZ^9Z2*$U>7Es9lE4X*WMJN<)~v|plP&lO30j7@O9``{7d6~ zZ+MkkWqTQlQ&`QdO08}YLR3o`Z||g`(bCi@Dl3a_P6&!_3M6aYOTJpT`-E)5p6B8m z8yHS|8FtElYa1)ETBMfM;J6^|Hzi_D`Ha6(x>0mui*ojdyQhDg!D#(I7ThU<1-HOz z;^mXWGxzGX8ClmEZ>I95iL&yyvgM11Xbmy2tXb8(@NM`cHVXztZ!6Q^wrU6O+mL3r zfkLfyj+!18$j=Mz_!KWOBo3Ifv$6Hpd$C1l9|T^2U9AlmR(whhcXW12>nzG>o-PY9 zzPd6Xwiq7?QytcaT;IG|edTCuz&}&izwVY@^Z2N1bGFYjn_om&iY3PJwG&<|piz4r zvM7Qhf!&wzjAMFW@00&Zq_OWOt6gYTH%@W%#@dA-i~oKTf1=hFy zo;&nJu_6s@-3ui#S?+v4fEQxznk|`s-b5s+OmXW(KKr0Q*LSMq!OgL9-8*Y6%vZ@5 zvx?0!9;v#3M_O7*kzccv8A{buFzF>%?u!taUr+&U=O@|~RRe^pDilfhb^IA!^ z1J{$(&!`mc07PmqmauKgZki6)l_dIOnXS0HDDh@`(|CIC?E+*3{``TAmqaq1!0z|_ z)O0?Co>(ux1?3!KTU0a^pa{Dh7`qauQ{c&R* zYK?SadpN^!pr4%2xGJV>c#3uzffSv4LLe~AJwC2R;mt;soa1{-+;=M}zfM8jS1^5X zO}7@EsfY`Mf=gv|3T;HIVr7RDv*H5)ImI%5+vdbg$|S>E>6#go+YIYeOe(9ZSD~1s z6Ky}5zQ_3l$$KmIsO-)|b)1IkA~Z*Il&zMSfn6X9*R2!eBu3FA(ZA%he}gVMTIJ-f z);|>ESX^(XwHOM-Z|z^FASWl91$(LF<8@<#w7a(ihlXYdBcxR6h80EWNI5hHnC?r% z`{t{<-#zS^9X^ViI1v>n(N@YuXYZje-DrT?VHyW zp9kMt{gRt81m4p%&%);#v= zR$zH@b)IV`v2VA+7~}hUQU{n-IVLvQ6@GXauug|9CHympzEC~LiFJ#pf8ptUsjade zi{y&og5fh00_#b%;}S~!VLSAZzoAool9jXyL~~GuquZF<6>+MqlxHe{EiM-D^wyAK z2XPS9AN$iHfK38_fdEwVw}7{ddQbZX)2hBE>8*YCgKxHz{HmI|I?m6l$)H1)&MR$m zbaV`0*+H(_z>yh*nF5H<5MbS)qZ%$j-m}O+^i)`Q3iQVxv86!mlaC?~JS(?|NVLF9 z8=W##rjEb={E1i|pED?K)+@7D+yQCA#8lI7Kn5#U8l4kC>JCS8bt{MN9$w&5-0Cki z-a~|W<$vk00^Y=$5!GO&SoM9GuSjX7S=YGhwg@F>+QuHGjVwD}_UqeZF{AUmUExjr z(jWiYhK*-FP*CK^4lY$IBB-#yZnL1nXmYAv|E0Xrd$oGyfQX4hFp28J~ILz((exOLPs zso}h9@aXD%sac@--@O=MoO3&FkY48beM-;F&Ta++JJ;%c<0Y>bq@Dc2EK4$dtLV-9 z_u+T$I6FDjt&owDUSPYw^dx+KSC+}viA|kB{I5+sq)A1%T(hdHjWle3WE;6V4vwHh z{-CKLqtg*8nr&2+gE-H4^8qeMPN8XQ?lU>y1w~cfkp=N&ri&V?s_Z+DkKenNp;z!| zQy|HuJ9(LeBI?pN;XzRn^)`WVHR*vYVJX_f;WoEwp3GG^N@E&dId?qkJY^51EPF(} z>zj?oWzBj#`t#Gd7yaQo_GBL0cCMv*^-onmd5S#?cWutyl*`rm52E&~8~A&Ao%-|s zmC~QI*f=4cvFrNv51B`P^Xf}^8hi}<9x1EVP*~^CXepL%glz98i#+7db+?`#!7U3g z%n~~~3gb8nOP4-L-`r?ZHEDl1C+#1{uJSand(ml&_C_MvoX*PT2KPw0+@b4xnGS?F zd3|iN;nhEGz1`=HP&zG5rl{$7AMa$$G@_4#yv!4|H}@82b_F?=mY2Wwk#rg(|1Pmt z+y^Sp+lAuRT|5Sz)~c1^J=ACCfBkyhdhh7y6s=5Cdf%6r+|1AWRZg71NWX=V)mIBG zt*x)KxP#PBpLWo!RI`~=5Hzk*x?FGknOwlC75sLvRZ#CDLPeyu`lWdNWZJ^LNvTO% zuCgN@qSw74FA!EL}8hzX!k3-@q) z21e@;?b&d-lV{ns>-8~@H|LuuS5lO&RFx&T%yu?~DmAQEm2vbZGMh7m?qxdpf>bM7 z$+CEH>C_DS#rf(CI~n$AWA8tUqNC*{`wKke4sK>-RG11;j`Yavv&=9Gw2rh1AR(E> z=LV~$@Mp2RAl(QahtS41DrAz9k}4^op>IL&PeliWO_Bf4QO`f#$N5C%94Y0@pIRvy zo1mX_)YsZ@63eX4DjqR&1CRQ`xiO|e%4L1ko$?xvxM7ve%RER^s?Usa|EUWdTV#`w zJ493SvJ0*6jsHT0MpT#0ZOdqK;uRC#g^uoJ(k=iNi5bJW`T4K(KX0Ms*n0WK$Le7H z8XS=p7W)O*cdtMorcgOT-Yl;-68UQe~)UWsGZl`(?`T5+{2T@VJQ3ZvCzZMoU zs6;QTsp1nGA91nUcC%cpI%kbW?Ey*3-l|tzlW)=)FTenuX7uXrUjsBp z_18^Qb#JKK!oRWRt+lB|T{ryr(ztDZ%wg9D-ZF}kK zNHwp*18j6|QC(0$cSffG8~Kj>k!y^tFJ@1KPuwQRE0vgBOtw;}^-VW2J+x@pTuSBU z?mp#NIWh@t3Pa!xZO<1&uFlTf=}cnQF`y6QAS~Y-VH{yK@afa`ATW&9N|hs@Asip= z8(zZU+`j26y;{-naqG4ZyVYCNpX3Seb5IkAbl-&rzrHVt zZq>`Jw}q-b&CSi>>f&etwUeKh=e8qV(_GW$3K_3>{cVwX?c*1yqi6@kh3UN|hj%iV zfOQ8;2Hv*e)G9H{X*aL_IyU0yrOv%8+xuRp9GoXS{SX$+{=D)50ED~O4ewhQ?P+@) zbALB(p+9H|wdj3!fm2!U3u{&$KjYQEpkSdHx2&qb9h_($MG*qGv`u4|Oe?fbaZ8 zl)p{V%C|4HAL%=e6D8!52CYAW%kPgYl$7-Q@9h zW>gTK*AF<`rky*>%64PR7mFK$T+J>)2dKXMg2=;+j)wl)vG-J$H`U+dnWc-LcH~=T zeCnGdyb&J_zk_ZBjcfe*B$m|xM6%pvdKR96M*g`?uRwD6!W`~)rBl&&qoZY4CNmlv z8$nlBjZR6epyYr*)=fV?4z1TnA<=1Dj*X8GTWZN#zv_^!e^>>1z=6J7XVZ)}bS2Al z*ljeo$*xW5dEa3;z0%uf|NVzm|I?cMd^3ht4R1*=y+20E-Zl&@rw!EJcyRun`{^xsLcQT9PCFfQO>zNh0I=7IH%1kkmT5$py1_~X>nHdCSpq40X(^r{_7uXNGWeM9}vgSHD-N4L4$?`Boe=D}VsHq~`msj|#>BHO56IrO#V_T5)(R1qDkyFT)4 zL~i6Bofq`?e^yrJDY%*UrXY0|+Fg06TcZ;Bp5d+Obw4mS1v?v(Lk?S|MCzwH2@x?&Zk8HX;GmnG2 zjcbom83>`0tvff6CiUX^51D3{Cn|p(*~tn??gnL?Z_TuCcfGh8XCdsDLQEtjyOP53 zbsuj{7XRI>&p%2COE!z0skpWG!qiz32P=#uZKwV_lC;)H{c3Mt6F?AR9{Xm-i)s38 zBJGTHil&suswm_?V%Fd?lfxnDo5YUg@bW;b;-;m?)d^xOyPpe*`D}wxBWVAo|khx zaQ(9Dp{UNr$UT=yu2Y1D^78`WmU?@6%d&f2$VFAmy`T|y-SUfi^rfkjle(dfkx^1~ zw4XW$naXRSnE{csZUg5bD|o}MQ;{zk=8E;c5Mtct>u=DXR z`wmbYxY6fuj>e2UUYeVtdl!=j5_2_z3DWWQU7oj!lesNY;uT00z+7|7F0Dwdcb%Ql zG8#J%cC<~q54$P;VJ|p{YE9v6Pm}F}>1kQD${Yu`d_4i9o=R3AY|(akFotXQj|pLRHD*{ zVy)gP{n@Rhz%AYSIs4g5HLZt%+By^LGc^1;1`0xJeNXnfE3rh~J2@S5*q1>|MsVkdBln1jmeEg=yO$%GY)(;)y)J-FK(~HA`y4d& z&GSEheo0V!GPk_X{7V`ZaB8s+D~lsD}NeOT0L;e^1SqimaD2>TjdU?NuG?_ z>k^#h>Er;txg(5C3w`Po5_}$w^4XJpdIBtHAtbIfmt8LW7_BpcMZV$US>m6Qb z|LwK;M@dgP-g7*-*7ovcnqRp0GlIu|0cGbeLfIL8ynf$CvgRw+S25wQ-;BvoNQar6 zFkgblL2jl&w*J;l`_{~tUFH>3$LcrxT!&ZEt#R=uM;|Ilw<-kD9!%$oWbew!%j0If zSjPQmG+1_hnM<3h-2w7WW`|!dN1xJ&Jq)aUr&wdMBRZTdW&g|mO<`^Hlx^OZL{>i) zyyZxHsFc?D%P_DZxAw}~GN1OYNkwaGlEU24rzUEP%FQm}Wh633$;$_pOJvBkIi?E)dSMo=yL9VvN-B>=hC3TU!rEBnJ{S5%d~d zf_rxDQd}dCw&}a{*!SqHAoccgD+W6Djx@cN^dQ=7QWveQDYuW+{bKhoKOixx926TV zaw?1caW-FUr0!21atey{OOe|G4@yWd51Bk>n?Lhuxqqu;HCsfa^o2mHp(=>~lh95- zdPMFJ*!L*#ycDwo#XggrFRjP~Xu^~(mZW%)Gn^a~bB}9qtlrVPa*=IBCP6LPRZ<7) zHB;!w4!iF9-8QboIO&0zlaKDm*gA*W^O^4Alyl}VZ3}PHjC{zFO6$=V!>fDMacYzE z@wDC?3jVgCt9}t|l4tew{OR5Vo51@GoJ4g9OTr=G-+k13j|mn1^WE{*EsZEtYYDC{ zWdx)uECWYsZaIE0sDG*n>a*S6zcn3-jlrTzfk~_CO&9F#2SMzdAR<%| zkt(p*fYL0P5bhvK=U#HM{iRD>wI^*qRV_6|aY3Q>lH+WQjsE7BbZK+tPg822%Q*u* z&77e)v2VS*(0d6+^jZB|KR5TC{oGjjjNGqe=b@!{4Pg%Dx6o}G44h*9Kzldq7k~@F zgDaflk7w)J$;SK2*<*6OZ^R`NBiB#{u7lTYQ+(1YyHO1dbYjiu4R@a2z4d@E=WGc> zpbgZ4L?}ir^vXso1>DpB(>p`ijqE+mW0L-uT!lJ|NA*LWo7 z&ajU+tNt!6)BBa!SNSHM63Cqb6uYfAy%j4*7V>~gySoimyK_;w z4nmidTXU=QElN~@Z7+`;g7Ei}lG5$MLP4MLPQ^QyE$y=Yt5LWG6p&&?V0>{Ls)i#?wsVwL{G<;rWwKehaTcCF%pP_T)c0KYlbgdOV5~jr5(6kQET=uq)q9 zOjPfpWn=rqJdWO}2X7CCaXcC8J+j_6klqaV7J@No2Jp;+U|$&i4kOX4*RNS1j^eR^ zMM1SDH8oY&Xm8blq(}MrON)!&zHx?6oH)Uw|Il>)bG^_l^Mji|TFQkl(|l2;8}g#? zsybS=U&MXE_O7q|A@@x7UXqfT;H6@frAdC#+b2C^Y`_kAd9A_VwSD4_y~|dZHSRQn zMG;62PBbcsUPQ^Z@x@v)d^jS(nY>3muTR4UP#MC!9-AHmfA!iZ%yX7Q#iNp@Rb5Gcz zL@F!$iLwC}aP(Pwguo=@1KaSB*Dp3w z1S=kZeJwL9jC9sCBO?P@i->NbrJ<3d!gU0vA0QUa2;fNRMpPH<>@a#=Fk6e6keq2$ zV#EyKlb@l2HRROP-yg<`+r7vni8(Ao`O}|TL(^;TZnZ@*Dg{644r+{E-9B!aE+4R$ zIcD*C$&Bg!{;*=Ij-w&5V);L;CU|$`s;8e3IRE;H*-(d&mSlX9at8a(8JXr-dFZ;X zqXYs&DXT~$t|S@Q@cw;)4oTG5weJpCQ}=d3Xy-<@$kJST4+4_sO#dz0#NQkig7Ey{ zg>;_Ybs*EO+dyPX03*QcFj#Jrq&?44j|W9W$hysb#@fOBwBeDFyYcZ>CCx6d$YA&v zq-v}dU&4oy-nl_z$=T5kXkNrSm zTNF@Wjuc=9T#tZ%z_^dap#&v@(nW~N&~!vv8yMWbp(&%Fh+_fX+E+PS)8atClw_ zQnu;Z1y+H3EEZfqUVZM9IJ!eP4 zI-hWGiM;i2-n8KP6(1^=feX#C9FceL7DEu;5Pn&kv(E?t`w7aXP8TZ{i!k*RC=KHM z*4M_OPZOg$p@nI;gEsQpw{H-ss~Fg~&rK@ucp&SUyMqI!$&;@~1xR)qUkj~it_3Ff z5n>b*R*zKR~4|*dsO7)az@~MszIf)S7BU77_~NHR6=SY4ut?2 zo)Ab;GE>AIZynWYg-{!xu?K(*NG-%XoROn~D+hFA_5~PoAnt%ccNO|2&5wg8{~1tw zZ!WzR18Ny&A~ms@??y{$oF!(X#sxXANKIXgZF+M9VD8#svK`q$wLJQ!i;o2pElMbM z9`-x)Me#AGzefel#iCa<<+?G4o3nPb-)oz~<@Wwcs($K6JRu0Ln8KoOg&>q_ii0v5 zl%zB)Z4g;K*R1TAYh$*~I<$WG{gW(luVCtrZEfsq5rIxNI1i-mEeeVfum2;Vy0io` z^=voZ^gA4s|8ZjCz}$iJQsmzfr5Aw>@9vAdMw|;V9Ynj*b_Oc*2~5ky z>E!gbrG@XnfiJM=4`Snc*K`7eMgNANC)n<755b&L=8Km#9|UTig(VTe@k~SBj_@8S zQ{(E#{FzzA5F#1EN{S!hYYU5uN*CeIw6?WXQbOY1o^;1|h{wW9M+(P&Uf|39I5I4r z78gf8fgn;j@+U@d-WF2sLV}SXKmT)}C-(LQQtd-S8yI_y)vFqujWpWbjEtuE|KS$I z?FKWIGJ&7ZC(+mp+!o34G;Z|Ubg;;UPWy|1;CE6Rs(?>o(QJ}1q*Y{0M1X)&HA zG2U$YeDARPc}mAq5+^H@VWEQg#F;W?WO&$j5F;&4h=zJ=+pDQj)bILqTv9UQtVb6v zC)}8n$97}UrV6%S+vB)cf+v8R=!A+&vmf-|jrmv(Fs^%HGzRxPeT<2|zP_<>{?Xl9 zZb$zw``iqtJFH{a={x>wrz6FJ*xhAiNDu9QEnW4s>rizS@ zuRxK3SBz5((}ow<^$LP^Y_}b%S;Lct-1h3VYb+QFS9k@t2o~1Jh!dn4Ftgr$|L5-H z@UXVN{&gJFF!E<%fTX!O4i1Rd0a2cc7kW83xVMOFrlq9?!a!nIH@yS>Q&IvRY#FyX z@>2A*v~D)I!lR9n6*{zgv9Sng_nCdUE$G3~Ho7B8vOYCp%?NmE$6}Lv3yoG<6D*rn z*6?Yq%#6q6wxv?;md?zi-)N>dYw&C(&b7EFGMlBAVq8sLYG0bs5<}jPD`p<|bo@%# z%!JR^cF(fdO>9<~o<99=9021#aRAc48n-A^c07QesEs}2!13e1aYR2t0@7h#9%y7sVmSGjwyuC)*1beC3GmhXFh&7q|}`plH8C6WIzJv_e%? zm57MIxMC>sehk$PagM9K{878H4u%w2Avz><4{aeojF5z_B7~)5aLxe5K*=E~DXHVf z?=vH=8)(t=*RRMQ1(n{H$g3Z1TA(S`F2~I!%CkUseM3>wK%HF6{zCD%zFnHpHmeh6 zX-c-cmg21vUmV%ve=t2YGQZe0R=AF|wZmPH!#s;+mV9k@Y)KQN6v?>9>at@1iDJvZ zzzx`Ui0LbPsBx36Ff;p%nIx3@mB@~{o2;@eC^S?t7?Sxe#S_rvX7(cy z8Ty$jbha~{(ryEYmT5Ymp2&daObSe?|J)SQ>(k_t!j^)u`aQBQjG9+@DZ8fo_6jy0 zr2%THW6FJJtPw8~xQecstKrkVeR^O&htt|t<_QXy*VXEe{_oa|^Z(1c;j zB%UMiz5sL9a$8$lgrN|9>({SeKYTdnL@^N`9Svdm5_ZWB=r*h#HY<*fnjyMU6>--^j;9ZuJzJZa|BB14e3i z1GujtaR)fCV78I}UN2+6+5BP?y)x!ablt1ZUg~cs5V-`a7XM5me#Tz@cN($O+*0=W z-A&}NxBSH3pK;Usx{>c{y}S3v6_%#2UHRHJq{hmX@D!<~5K)tuv};`O__6gsg(Ki# ziE#;&{u}p&m%H^%KUH2k$j<)a?c3ahn)SW1-i53Jr zp2841x@RgB^e2$%1wWPD$Cf0UNz>&5hqcu68gA zAh>28o%S1u_J&cX57!629fd+N(i{yVR2v|+qqr^b(V?z8pJ{{>s}$ioupgzt(o5FX zg?j`+@E4%4C63X}18SO@UxK>;@`YUA2Xp99=TYOVq2}ui66P+4JlubMy8cE^{-dbe z*|K#_$uTt^{dKlXd5Y%jJcYOEACHwRJ-wrr6K%t*`JgUWrVFYIVi!@bt_uohtz!&lnol*VcjKsAB)chL?(?yypc_a(^?349IA=p7S zeyAny8=pMMB&EdjKa_vn!4T5p^*Iw3$j!Ju)3&Ry>Ksomoj@cJp`xew`$Y^fA_1JYW0Kt0^EXTq>e9^PpzJ2`{#uI&c|3JOv zO7;cJA;pt9NTN)xwtZpw7n>c)J(DdDc25b5ExGJb%ll;Cb8yzi&c@K4Yt(a%%}SS* zXumjCc8z09UNtUQWdEXy7V-t=1OzqoUSv zp!wLbW5`QDD4_KLk&U}S-0mZ&a4KuyheHX5i3O^qD7qvuw6FAndE%gwpT9pT{|yvZ zyl?#>j(vcf{Hrc5Sy8Cf)AyDHk6eDAbmdI-A81uVW*ybdeIHXx1-?8T@tltNOtzMz z!eIORcIMQX#|<={jE#4Xs7l@pZ75IizIk%5!Q_f0Rc3XDyi7;Tm*-h_^fu}FMOk@c z!q$(&0edyl*Wm^-D6wjXJA5arxR#+|$cHKZ3Pr04XpsN~oM1%5gA*AhAe>L>rtO_b zt2h}5^5Y7|j3c!I7W4-p%#R|e1NNc-MK9TCTHvmQKI3`JOZD1uMa6hAuSEYJdmez15jH+* z9h~>bEKEsCYT-_aj;JW4ymuJbCahdgf{&3Kc_~RWxcB-B2Htp6IyqgGZ1Ycyca9*NhVakyjUv` zD2IelKvhZ)|LxB92GkME_DI;^L~W+86RC6ZY zb$y7En!0g6Cc33>d`=#&)Q)_OL0SUfE1v?7K2{N_KQ;R4 zPq5L$q4P_ZfEl>x8$P|ZIe1f)GM^ly;{SmYbgGbGGD9^TR z);j?!uU;J6+F40D!)+Pxkx@GTqD}BCHD0ySvpl_E>*u6Q%43D#+^U2XCN%wQljm_d z%(U#sfezI5z_kqJs~Ws$Ym1NH1j<^|-2Lv|yHB6IunOZz>W^qZWv$Ij^%Z`q_jI0?C#3NWE6EOpoyoEEM+koUiYhB$%lnZ0! zrKPb-lssbW`!t@!q|ZZ?rn`XCd0fOtNojv(WXdh>BO7-BvL|qeMQf6@DL6Sfp{DEB zWtDK|0d|S&@R@nW-sD%MK@U8vC=ZPWJE=RU?EJhx(}?L5`Oy3K)WbI)~g!*oGyiBDwv%z}PC6YtH84DUfrIw@;3TZky{8_G &9^D57TU$p1vDtA@*u<;UgmvKGS9wggz^&hHSbT-$sBHxfI3yfEKB>B> zY;KjbH09JgIXQ{r8ao{LDgJEa*)Cfmc>B~%&}j`j|8dBp>y1mjMb052ff{$HxiR`RKs&O@bW% zAournIb5-P$4itt&NM3q4=9XbTEYUyQ)##Y>mAB@6n}t69Bgf?U8c_haKWy>^VE9B zL5yDkF0?v3ta!v>prRd7i>axfv9+O5UWR!A*&DgK1pqWKPm_m_Z*gHE5g~#!c9oq( zxU3?ic&C*sw``q9`@ne{XCE;%7I+T;M}`6sDJjBUcj7%tLrQY;_~X|e^0`d0lRrhN z&y$RBJ18i@+OCufQwLei~-_k74aZm6EUj8Mn55Q7eNJtbF6#*k9WnVzy0(lRl zp>)>U@9dLJ=7juYpVwkLL_O+iYM?kE@;YxcE^(zJ6OB5mX8WOju8=hYlQ{@$s=YN=msm9uR*k!7x zKdHLQ!+V5ms5`Z{^XT?|ub63M!-4{2lE*J1f)$3vDin@}7s9Bp|9DI&iap67=A0S* zTO||sVcia>-@r#F^7f*1v#_*;Vvpab;wlPoLLunnWcb{}#n3Q8v+}Mxz+x;l{Mx8= z@qn>2($dr4HdjqL=CJn&aqQ5I5{N&bLO6um3o^79^EDtTAs8tJ4Qiw=(c)!VnB1>t+NcYPwsY5uW`nwmOY^0&n; zftN0*`GNJqmJ;-H>UZ&vy&e<04Wq-@BY&c_1q==LXmZcM_->E1iPTVJOQagD5X5IC zdqicWd-l->RwM+%1T67CjaPbz48qqiDK5vq`8GD$LI8w;3FaF-{Xzt|6`Gz_QyVdb z@d6|O{OxdnAieC=1Bvb+r`Rx4R9GI!j0Frrq=AD&msn ztcAdOYI`FS5~@%c13M#ZoSa3OnGHQXapjl`rv{DH$jY?o#!3Wcze4|t1%*aevHaK0246oAqbxwSSSyYZW_u@W4?abhm*28dGjdYZ6RDoRQz zkeZmC_8W0&rxJW65cvQC9b>eJx%S8iib_DqebNTqld|OGVlGsRqx&|NiOC&?SV^ee z`{bH^Hh%f2k7s3NVVUR8Tp={u2a$yP*{1Nh%F%jf#b!NgcJI*yA-#gBA|u_%)bFRL z6bv2JJG7&(a@JXH*H;fXR>FVrq-Dd&)`KD$H+Rpo=VZTn_;r$foQTRqvH)iauAq)` zpXK3gqAuvV(d|2nf+5wbG5fd}@f%YH3n2&L6@=!5eUR|+3tKWYq9TJ*>u&V4x_yey&LzNVK*M7hpn0a*yBD53qvJXCfRsSBadS5e4%QrpP;%57 zWeP8bmp^fy(pe`os|-^mFIx#PILsQi&SKf~bMOaag)HMcW6)U%Tw34ov9Y*Xnw!C( z?D{d-*7gLKIVP-kb)9iShTgW{?4sP)kos;gmvH~SJGvqC;$SZia&t${U}pxzOyl^D z)PW1*(xp*|@KoQOspC(6>lb+*IdTm1M%F|Uc57d)k8A=a76N6)#F7tKS0`KFzJ2!3 z>{N8}<9GN_c~NwHLDdQiqb|U%%LpdG3&^s)m*=$K-}$1*E#Z1+32qW!oq>^&(q5D3 z?i@KCGHO;HQvc=DWgBMm;l~4<{p@zKP9NoNO4YV&{+(@lDnMUMRg}k>pDE#(-id6U zUO0KW8fvXV&XcqLmJ}CnQzY`fpkl%fkLH(=8T|A~T0r1C&LRjx&_~|YD<~@~8yp;* zv_j=NmUaT~m7R~Tg|Jy7;6zE^0L@wm!{J9ECJkZ~>9QC?%!ni?>sXtV|IAG>1>6Mb z3GrpZ4yk6_Z5jDWoNrjz1W|`2^zb2Y3mk_Di2uYU%+|Pn(cWIh@;&R$oe?OZKt3Yx ztEsmWd9Py&1uLGv{M${s}lEK%;Np_}+Ts zc=$FwEeVwXm_%gxZQYJ8*0|a&%30~yg}SW`rO-JvHmY<$VR0SjHYO$OFq9+y(Rb?| z9(|q0h<&wytu!gLCI#OIySvLZ_Hr22LGm9OzcK+%r$T#OjTz0AB*Tc;xPm zWHeQ1QZUD=tGBmYwX)gcJX&&851yW$eY!xiF;oMz{HbKA`>q{KZb_h&9N(Cx+cn2N zyZyjf_V?fr4>7ItUQ<}Hv-U2#jk6uJ>o0sshT7!lvjt=KCw|+`?n&NMTda7PiDy6k za6faVq5bw>hsJWcxT4bJ)qQ+5wF{tvOIJry5UW9Zc@zilVkEkhMTKK>CwQW$BVOQE zi{w|k5OmU?a<@vfZBUDxqF2`ou1A0lCeS*XNi_#BET-V zYI@wFcv>5Gy0P&Z5(K^1mU?jtds$ubg#ynA;f^?kg9uhjztChnAENQlHcXN;ugG(*P9lHRb;Zuj2^(wSIQrr1I>fQ8xVeI9z z>7&t-@k}1S!GlARFQ|JSovYJ#qF6gr@{OfW?L&FVbAzej4zfDguiOiXeU}UWk8Uyy zG>Q-~GB&m#=|NSWK`RP-zTwI$R%08C;~%i#DyP4Hw^yp%5;HzLOv}V{G75TPh@b?J zLCej}Eh)*OjO1|-2#1`XAPh-s7;zgGjFL$LoLfS*g(wgDELP%sW&p$P z-FJPhpI29-KDRm=v8x%hS_xQqN}l%}9fV{941!pJf?_|MG1 zhQsQoDkIr84fee7&-0FD+f?zHcG0{&ROqQrzE*)$f~}5-Ui)w*jfu-mvSIVe$o;Ag z)~_wKPhZz90AQJ#lEH=`wE%+GLvRt|(tKA6j4F$XM8)8o99~`&rWMF)#p(qHtP4Wm z{P})VN*ifhQTLxgj6Wb|wSJ9g+&6PI6J=-G5CP2p^Ci9WZ&N0#U4SXQ09kN|AWq>( z%TciDsB8bxODQ&Yqt-xGhRUa;W0CKmtY zX!As4T@e6-24qrPCU#G1tvH?H&EF( zUf!MHU>sv(fG4oe-$StiWfI;wmKhr>D=rGryl{=@Pm63nIU&BH2U&FyB-bIZE zuVbW&$<8*N{U~u$>aOm5o9}P^bB4SM6pSRLOZhrl?Dw}>pQq7EI=?bH7RMJWW}8im zC7>;R2>rc0UsGFatHawkerG>ienQqXY!^?k!9HTeI+MY6=B68l|K#Q8PkqIvlRE)6 z9`Y?oj}xDsiA6@+LTD;KACnb;(pTgm00Ng)6|xP#D_kMIJ5$y(4t9;^$h zWHN`@Z$Lt>zkqo*AS$-^6IH<8oyuv>mjklrW=wB!pm4ilZ@(Vyy5~W=u}TlD^OJ5Z zA9hg5`CQi0h!^suZ1HW;M%g(QqXW2vlf=sff{zwWv>)fFRNg@Xd0+efp{e@Cs}Czo zWoDFbX>XYJZTlhp@Zr5(&YDX6Pv7}Sukoav_B(cWC*S(1{&R9QGn0Q!2!yK+Uv;4X zAAmC)qHS;^wlq*WMA9uj zH*%?X_N@Qo$2lv%z-m=Q%3wyst%vis{^=aQh{5iLsmtG|rcVAD7jOu<`cU&hNDLC8 z_c1e9nJ8k1gV`@{;C&IipF1%bt+stXZQccP4lz z`;$Hy`d5!(@@g}rb8QKP(u|U~chLRDZnit#%CdLD!^@sLv0C5t7RJu0G3?ItZ2?gE zVv=IbYtoGl5sQ!y*pRs5Kqe?TWRnNnOul1q9DOVLEg`1(U|euWZxs41GPVD}fpy4T z&%A1|Sgc?SD);=k%=d-rc0^vqXF;ck0|(!3G?((XXa`9Ki_tMO;SdE@AYR__*N}~&D($5U$}IV zsnGY?1J2Wgc-m0WMmGNWxxcdS2N|mWBm2I`xj6AZy69Q=t0w@&~*O|Fs|f z!QDVl)!gzvS=s#hn@Xjs3lqBdM+mX$^ajBme8=vP^LP;3PX2%8ke zDMmtuhli__OWeYBgWW7L02uJM?X)@5&0PmH481R_;xmG0nRt z_V|QD8iz(-h7pqm^!@0y@K>>_ZrX~93N#%^w{&XHS$Z$A#}8u;PCNy!f*#nbOwAee z`M;azACM2r=zoc^ckmM8Lc9!sT-Y6R2<*V^3?3MYJZKP#0|QT>pffi91h5L%_%2pf zz7z#e`;dZ)a&V+!nh1g`um`wXQ8ZbQ+AM^T^3bud86&n0%_U@OAmhQ7Ltv65;SwSx zWvpGGNXetWZQC|!T5U+^I1ym&!2Er%jaN!!yt$L0sPD=BfzGwD{2BQHwp63J3r_@{ z&77E>axi^f-l|Q$k0b2Pk+jv@wy|k}Wc%0H?EdI~am}zT*3QejvOnUD?%8`vIYwpS z7B6bDM#%i zq0xj8dxMaUkRVp4Tffo+oPoPLp{GNShrVzx<~?9HqF5ouE>|>nVGXyi`P(;d9IszL@wr$U^4mRk$9fXBAOY5oT@F$GEftt(+?nS#pt2KtLt4gL3TTIJOR$ z_c20Vj&^L&XJc(+Xz;mDcl@y%tQ#zszf?{b{91^w?3KQB-*0;qwbJ;RAm_Vno3*3v z^6WozM(xPd=Npe$4{-<5gfOc_`wNGb2Ee2uhq;^R<%k0{qATZ>~qdUtxx zco`2(dwTu)^`!H4cPZMf<&q}7dI`He(NcM_eNt{(rGT6D6it!h+t;@O(GTJzj){r_ zL&95-?v7=(Yy>MdRs}+qhZ|Yy=N(IbNZ>?B4=G;t9lseN|+NPoUIOW93 zrrzDp&ur%3Ed4b*wk+{#8*^DmPX@2;79+c38=Hj6#k3YQqz_Dp+L0?LvhPw z^8vAdtfUP6G{Q({bWEWi0P3LmtYi(C2~LMqR0sF?40O(%$-LLMhk=18P{9)=HZ36- z`RgO}#^}9s_kZmG!_SagSg0@Jtm9Hh21evCKmREUsKH8%^Q(Xnf;&szU1?2?nTxrZ zk9CGI0S$`*U~ODX%w)dTpP6#$y_H`x&h4w~%U!y-#(Gpg&Dy!dSS)_enf-by`_2~D zu2*@}U3vDUx-2n8?NCnZLOEZ#Pxnr~wG7n=W}|84!k)L6OASv^+w9KyEpzXHH0toCO;2o; z3fyirb(_#s$_R4yngld0S-5p`;`R69G(~TOSMtCs4#gM79U>nB2=+TjYfoc(_9+0z z0ze8WDJi=eW$ze0`wR|zW`>Q0C71ck>4{I$+0#vsru>{PtVMjpZ`8o>jr0_ID)%$-o#5WkJu?#w`a{L}d-~QtH1% zto>P>S~&f7DZ8)##~jt>GwT55J15GHekCzlO5AzXPFvbA*yD&8*|S|HQs%( zHn8g|`jXkxq!-cSq4C}(x~ViL=VZoHlu|ew1OIy5euF~MX|<{TK2(dTj;Zf|fysoB z;cPD1el9LWzrnf2EzB!muhA;zS77nsEnAsf;k>^Ku1key$jyDY{Wn;oD;e*J0cRgR zJo;D{hOK?mn9J$aE~SE>>9SGN*S?+}>wEU^?p_Jhuf)hz0*Tkx$0n1MlB(|1D*&eB z;^c&xMQy$SzVT&>P8^OCw8{vediRSE>7c}c_&e*;Z3!&L$jC@+kAm-Jh<1lqhF?&y z)@|-0s9HATagW*0F98EWsqOCR3CL*!I0c?DP6=FMnrdqJb6<(H^z;#Agy%hetn(X% z5o(#-G2}nFq910kcW}@@c<{gh>w6L+`}b$z^BF4@U<<=1&^Zd%z!@jhgD8%Lf| zk|d!f_>Lj(OJDbOzM;IO_EDDFgeJXhUrl8WIF*{bTuJkn8GNVxh5E@#(UM^3M$v$~ zTyD3=T3+RV-rKXMTZ;PZ<2nKbZ(Q2Es}hhU+8ZqrVX%PJ)zv_5vT!cLzj)^r`WHwk z&*&W1)K$3D#p~3~181|1E5Z*SC*zEqqG9L^7QK-`fZSbMAYTV0{T+m;3p=qtC)t9G z=9FGHb%Q`$j~)6o-YLkil3%ZM&{}cg(=yvyUFs00s1EaiYri!n=$7-Z^ZWE(@z6N! z@mjRg;oIe5tDFQ`$r+>cU&ZUYPmNhC;&fjL#3W#(#jitF57&V#e<(V|*~LZZ;n#7Q zyZ#s?dX?c2qC}1FOL96OH>Wx*F8V zOt&f(fCKP=vEe!}06Y~i!HMLOhYzbihm+0&(~4#=`m7{6x>KZ9hQK-z*U*;;s{y!Q zV4Shg95abpwdK-OCgiH+>t`pt)y60{>0N$bubuSVxbXWj|E`q>)A(RrVBF2zzU#r;gxmzV9u1NQbye!ItHT@hYyM4o zFgSNQEzHk1x3sKcf?p{s0i|1aKZbmTs#kzp(lDF(;=5f`20A)- zzg9vU1Ia3OOvzp`k;8`$1yC}jS7vsi(8xW9xaj|UooJ?-8(z9r2injs7M%&8Hg}? zzJ%?Ql|euz21g>?TYE@G#HpLNWDVuFgeNdTIk}r;1lv=nI%Yl6Ue2d6XkDN$R?Zw* z6Q{1Vky~%NmPp<`peGk$=xAtR==EiTv{c<<#h~V_`eAL$8`E?AJ6b5jw$(Vi(KuD` zcxA)lcMqTRMw=6ffsJ#2PH8|sr#`UYu<&LZHt5T7n*eUAtf+v#!vN({ItoVXY9bvO z7c=f?8M#XEj zUHE>$Z~#i;sgL3z0b$oS&9QWD0v7|1&udwC!Cu%XAEn@y$~Q*w^YsPlDHK-OnVES; zl}ChywPWGI!S{Q7^0C$_I~yBM!?CI>od+Ihc7w|@gCPbjqsJ9aed)(hxWqsscji2c zr}HuBjhYUOODwP+Okk-#cRyEGOkm%QSzn4txoe#_%lY-o*8Au8>mP9He*25B#n;fU z=iFJYKV{=92Kj2b*~y9Zdf9LE^lG)>5^yixJ5n82;A=DE0}mQj4`Ye6P6E^ zTwF@A&+yo&_sQgCmE-nDwAHC(0t=|Q;f|9Z8VQfC#ZJm8PA9Sdr@E_+hANN4SG%#j zh^@1>t|d&(?Am6t#w3;qQAlM+#2{NogDof1FgR_UsLjFJVN2?8CTSRh+IA-mS$p!* zRKk{_B`+H?Gm*C@(UEGEwNPHbq-V|m>^ zo12T+TnNHqySv0U?;6gE6Qb8E^G!bF;iyYNu(XV!iP(|OMJfd23gAdGWo}}!0e%UW zwgZUq?%jR`PH4)s*4kPNQZiV3TmAf~!k$2&gRy?052ho9FN4qsH-^?zLxY1bE1cwy znZX=Xa6&>}qZulI=}3%wO>&AGI9=s41YcN+iaz{Eb0Htpdw@WdMMWOfFSrKiE&@9R z5{kDDwSn@`>fPo3c?h1QB19V`eZ>U`oCLySJ9yQdS3FCz!NN8R3J;H6{ySpOk!T(d zwWqsE<=DgoiJ5^z{oze4TA<)fS8VOUFLuf?vu*TsR_)8X_2Tvq0_Iw^2d2%-e>+=k z9hJ6ceTK?pav`_#)$@MShLQ|LtFG6i^Fhn4$G(krEssCFUOPyJn8n;Z5?!joA(^W( zuQ(>+0R}9BP@+vJmC4IMcFxPrjzeq>&K5H7=+B}S@J>Od6)N+0A6BbN)?PN$Ri2z( za+<40xEfg@CZQwqsCFNid6Kk!z#5r1;0(Nn2T}Y-FB|{}5b-I5kdqxou!k%s0eq0N z_9EG0&?Jgy{U9fYbmX?nkE4eX5m;JUYBU;{#Fvwkn6Ngk)7?jAg0YXslu#oTeHVlT z9Q~U)K{cG9AmO7YkSZb#iRbfKx-|?2P$`*FfNM>lzrQU=FrXa3WefVyfQmzRa&n^4 z&`x=)JdY2qna1@YRUwt{N5M$)Mjq2qs?P=Um4G1ONwc!`muKq?5WRLLzc?tt<0 z>hV;wLw;Q@5c?HxlZjMr5AUU>zHeCRfQ(+6-q6Fg3M7culz&a*S6gr!hOOevZ1%cB zaEKX8I9v6rkbNU&7>fkWl~8zNx^6%JObq-vq$B9ajS&CQc$EXv160SqKY3C*cEiOM zMI88;_>JyHuwB;3^1e<1pQF&8jYJapYfS>gVStDQ>Cnf|L_l#z&*!X{^4->f{!vup zSl`A%9)s8Vmr(Yr>FGqFaOvtzkMG^{G1~|k=$T!WC6PIm=lO6%VFu^PwtaxS9rLB( zUQ&H_-6xlsq<4U_uPI-yh!{_7{_?>3EH6#H7_MQKK!gQGXA=s(vtRk3g-@hCr`C$V^v zK^ElTX|<_3%^mqEE30Lc)&V6t?$=V&4P|`I^9lP5Zt|+Pw?>a3L^5a1QPDG5no`yC zp<)4X+}wjQr`UawC}zqcPM+XVK@egCY8zn{nCv#t>5PqL^Wnp0YTI$8s=!zgZ-k9r zZ!IxFU{8ieMR&^oK5U=8=$UEk&GDxfVzq;F@k>@rXn*c%=8a;InfQ>oq?RGjnVM*| z&98@ZrdKbkp)sq%ToRjXE$7jO6UvVW;>ZtNJ#mKxwa}eKEf^~nsO9tKVTwkr&ZTj; zYiD|i)^zp0%=tZ7)Rh@q6Sweq4*&}s!jxKogAxC;NB1de^=2=s=?Qw`z=sB*LoJ(d o335}Y_4&`Y^FMj#zdoHK2)k#7Bc798)PLEw#m}R{ZSRS{0o`hkcmMzZ literal 0 HcmV?d00001 diff --git a/README.md b/README.md index 08f52c4..9f64902 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > 이 프로젝트는 **Spring Boot**를 사용한 **RESTful API 서버**이며, -> 주요 기능은 **사용자 인증/인가(JWT)**, **게시글 CRUD** 등입니다. +> 주요 기능은 **공연 검색 및 예매**, **게시글 CRUD** 등입니다. > 와플티켓 안드로이드 앱의 백엔드 서버 역할을 합니다. --- @@ -14,13 +14,12 @@ 2. [기술 스택](#기술-스택-tech-stack) 3. [주요 기능](#주요-기능-features) 4. [설치 및 실행](#설치-및-실행-getting-started) -5. [환경 변수 / 설정](#환경-변수--설정-environment-variables) -6. [DB 구조](#db-구조-database-schema) -7. [API 명세](#api-명세-api-documentation) -8. [테스트](#테스트-testing) -9. [배포](#배포-deployment) -10. [라이선스](#라이선스-license) -11. [기여](#기여-contributing) +5. [환경 변수 / 설정](#환경-변수--설정) +6. [DB 구조](#DB-구조) +7. [API 명세](#API-명세) +8. [배포](#배포-deployment) +9. [라이선스](#라이선스-license) +10. [기여](#기여-contributing) --- @@ -29,7 +28,7 @@ **와플티켓 안드로이드 앱 백엔드 서버**는 공연 정보를 등록하고, 사용자가 티켓을 예매/취소할 수 있는 기능을 제공합니다. - **목적**: 오프라인 공연의 티켓 예매 과정을 온라인 서비스로 전환 - **주요 특징**: 공연 일정/좌석/가격 관리, 예매/결제/취소, 소셜 로그인 등 -- **추가 내용**: 관리자와 일반 사용자의 접근 권한을 구분하며, 포스터/백드롭 이미지 등 공연 상세 정보를 다룹니다. +- **추가 내용**: 관리자와 일반 사용자의 접근 권한을 구분하며, 앱에서 직접 공연을 추가할 수 있도록 합니다 --- @@ -56,29 +55,52 @@ ## 주요 기능 (Features) -1. **공연 관리** - - 관리자 전용: 공연 등록/수정/삭제 (제목, 상세 정보, 일정, 가격, 포스터, 백드롭 이미지 등) +1. **공연** + - 관리자 전용: 공연 등록/수정/삭제 (제목, 상세 정보, 일정, 포스터 등) - 카테고리별 공연 목록 조회 + - 공연 검색 기능 2. **티켓 예매/취소** - 공연 좌석 조회, 특정 좌석 예매 진행 - - 결제(단순 시뮬레이션 혹은 결제 모듈 연동) - 예매 취소 시 환불 로직 등 3. **회원가입 / 로그인** - **로컬 로그인**: 아이디/비밀번호 - **소셜 로그인**: 카카오, 네이버 등의 OAuth2 인증 - JWT 토큰 발급, 재발급(리프레시 토큰) 4. **마이페이지** - - 예매 내역, 예매 취소 내역 조회 - - 공연 찜/즐겨찾기 (선택사항) -5. **관리자 기능** - - 유저 목록 조회(권한 관리) - - 공연 관련 통계, 매출 리포트(선택사항) -6. **캐싱 / 성능 최적화** (선택 사항) + - 예매 내역 조회, 예매 취소 +5. **캐싱 / 성능 최적화** - 공연 목록, 좌석 정보 캐싱 - 대규모 트래픽 대비 확장성 확보 - --- +## 주요 기능 구현 방식 +### 액세스 토큰 발급, 재발급 +기본적인 유저 인증은 Jwt를 이용해 처리했습니다 + +accessToken의 만료시간은 15분으로 짧게 두고, refreshToken을 사용해 재발급할 수 있도록 하였습니다. +이를 통해 토큰 탈취의 위험성을 줄이고자 했습니다. +refreshToken은 자동으로 쿠키로 전달됩니다. + +### 페이지네이션 구현 +무한스크롤을 구현하기 위한 페이지네이션을 구현했습니다 + +no offset 방식으로 구현을 했고, 이를 위해 마지막으로 반환한 정보를 나타내는 엔티티를 가리키는 cursor를 사용했습니다 + +cursor는 id와 정렬기준값을 기반으로 서버에서 생성하였습니다. CursorPageService를 여러 서비스 로직에서 범용적으로 사용할 수 있도록 하기 위해 신경썼습니다. +```aiignore +/api/v2/performance/search +/api/v2/performance/{performanceId}/review +/api/v2/review/{reviewId}/reply +``` +위 서비스들에 페이지네이션을 적용했습니다 + +### 좌석 & 예매 구현 +공연의 좌석들을 조회하고, 해당 공연의 예매 정보를 조회하여 아직 예매되지 않은 예매 가능한 좌석을 반환하도록 만들었습니다. + +예매정보를 저장하는 db에서 같은 좌석의 데이터가 들어올 수 없도록 uniqueConstraints를 설정했습니다 . +같은 좌석에 여러 예매 요청이 들어왔을 때 하나만 통과하는 것을 테스트로 확인했습니다. + +--- ## 설치 및 실행 (Getting Started) ### 사전 요구사항 (Prerequisites) @@ -92,21 +114,42 @@ 1. **레포지토리 클론** ```bash - git clone https://github.com/yourusername/ticketing-backend.git - cd ticketing-backend + git clone https://github.com/wafflestudio/22-5-team4-server.git + cd 22-5-team4-server ``` 2. **환경 변수 설정** - `.env` 파일 또는 `application.yml`에 DB, JWT 시크릿, 소셜 로그인 client-id 등 설정 - 자세한 사항은 [환경 변수 / 설정](#환경-변수--설정-environment-variables) 참고 -3. **의존성 설치 & 빌드** +3. **빌드 및 로컬 배포** ```bash - ./gradlew clean build - ``` + make + ``` + make 명령어 실행 시 빌드와 docker를 이용한 배포가 같이 이루어집니다 + + 접속 도메인: `http://localhost` +## 환경 변수 / 설정 +로컬 배포시 .env 파일을 아래와 같이 설정합니다 +```aiignore +SPRING_DATASOURCE_URL: "jdbc:mysql://mysql-db:3306/testdb" +SPRING_DATASOURCE_USERNAME: "user" +SPRING_DATASOURCE_PASSWORD: "somepassword" +JWT_SECRET_KEY: "???" +KAKAO_CLIENT_ID: ??? +KAKAO_CLIENT_SECRET: ??? +NAVER_CLIENT_ID: ??? +NAVER_CLIENT_SECRET: ??? +``` +JWT_SECRET_KEY는 32자 이상의 적당한 문자열을 사용하면 됩니다 -4. **애플리케이션 실행** - ```bash - ./gradlew bootRun - ``` - - 기본 포트: `http://localhost:8080` \ No newline at end of file +--- +## DB 구조 +### 엔티티 구조 +![EntityRelationDiagram.png](EntityRelationDiagram.png) +## API 명세 +로컬로 배포 후 `http://localhost/swagger-ui/index.html#/` +접속해서 확인 가능합니다 +## 배포 deployment +## 라이선스 license +## 기여 contributing \ No newline at end of file From 2aae42fd1878a6e237f75bd19b3f15304bccf07a Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sun, 2 Feb 2025 01:29:05 +0900 Subject: [PATCH 141/162] feat: Swaager summary --- .../controller/PerformanceController.kt | 12 ++++++++ .../controller/PerformanceEventController.kt | 14 ++++++++++ .../controller/PerformanceHallController.kt | 10 +++++++ .../review/controller/ReplyController.kt | 22 +++++++++++++++ .../review/controller/ReviewController.kt | 28 +++++++++++++++++++ .../seat/controller/SeatController.kt | 21 ++++++++++++++ 6 files changed, 107 insertions(+) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt index 3f72899..4327dd0 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceController.kt @@ -32,6 +32,10 @@ class PerformanceController( } @GetMapping("/api/v2/performance/search") + @Operation( + summary = "페이지네이션이 적용된 공연 조회", + description = "제목과 카테고리에 해당하는 공연들의 리스트를 반환합니다." + ) fun searchCursorPerformance( @RequestParam title: String?, @RequestParam category: PerformanceCategory?, @@ -47,6 +51,10 @@ class PerformanceController( // WARN: THIS IS FOR ADMIN. // TODO: SEPERATE THIS TO OTHER APPLICATION @PostMapping("/admin/v1/performance") + @Operation( + summary = "공연 생성", + description = "관리자가 공연을 생성할 수 있습니다." + ) fun createPerformance( @Valid @RequestBody request: CreatePerformanceRequest, @AuthenticationPrincipal userDetails: UserDetailsImpl, @@ -76,6 +84,10 @@ class PerformanceController( } @DeleteMapping("/admin/v1/performance/{performanceId}") + @Operation( + summary = "공연 삭제", + description = "관리자가 공연을 삭제할 수 있습니다." + ) fun deletePerformance( @PathVariable performanceId: String, @AuthenticationPrincipal userDetails: UserDetailsImpl, diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt index 7fc6ced..a1bb6b8 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceEventController.kt @@ -2,6 +2,7 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.service.PerformanceEventService import com.wafflestudio.interpark.user.controller.UserDetailsImpl +import io.swagger.v3.oas.annotations.Operation import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -14,6 +15,9 @@ class PerformanceEventController( private val performanceEventService: PerformanceEventService, ) { @GetMapping("/api/v1/performance-event") + @Operation( + summary = "전체 공연이벤트 조회", + ) fun getPerformanceEvent( ): ResponseEntity { // Currently, no search @@ -23,6 +27,10 @@ class PerformanceEventController( } @GetMapping("/api/v1/performance-event/{performanceId}/{performanceDate}") + @Operation( + summary = "특정 공연의 공연이벤트 조회", + description = "공연와 특정 공연일이 주어졌을 때 실제 이루어지는 공연들의 정보를 반환합니다." + ) fun getPerformanceEventFromDate( @PathVariable performanceId: String, @PathVariable performanceDate: String, @@ -38,6 +46,9 @@ class PerformanceEventController( // WARN: THIS IS FOR ADMIN. // TODO: SEPERATE THIS TO OTHER APPLICATION @PostMapping("/admin/v1/performance-event") + @Operation( + summary = "공연이벤트 생성", + ) fun createPerformanceEvent( @RequestBody request: CreatePerformanceEventRequest, @AuthenticationPrincipal userDetails: UserDetailsImpl, @@ -54,6 +65,9 @@ class PerformanceEventController( } @DeleteMapping("/admin/v1/performance-event/{performanceEventId}") + @Operation( + summary = "공연이벤트 삭제", + ) fun deletePerformanceEvent( @PathVariable performanceEventId: String, @AuthenticationPrincipal userDetails: UserDetailsImpl, diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt index 0c0eb6b..38a28f7 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/controller/PerformanceHallController.kt @@ -2,6 +2,7 @@ package com.wafflestudio.interpark.performance.controller import com.wafflestudio.interpark.performance.service.PerformanceHallService import com.wafflestudio.interpark.user.controller.UserDetailsImpl +import io.swagger.v3.oas.annotations.Operation import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -12,6 +13,9 @@ class PerformanceHallController( private val performanceHallService: PerformanceHallService, ) { @GetMapping("/api/v1/performance-hall") + @Operation( + summary = "전체 공연장 조회", + ) fun getPerformanceHall( ): ResponseEntity { // Currently, no search @@ -23,6 +27,9 @@ class PerformanceHallController( // WARN: THIS IS FOR ADMIN. // TODO: SEPERATE THIS TO OTHER APPLICATION @PostMapping("/admin/v1/performance-hall") + @Operation( + summary = "공연장 생성", + ) fun createPerformanceHall( @RequestBody request: CreatePerformanceHallRequest, @AuthenticationPrincipal userDetails: UserDetailsImpl, @@ -38,6 +45,9 @@ class PerformanceHallController( } @DeleteMapping("/admin/v1/performance-hall/{performanceHallId}") + @Operation( + summary = "공연장 삭제", + ) fun deletePerformance( @PathVariable performanceHallId: String, @AuthenticationPrincipal userDetails: UserDetailsImpl, diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt index 87d0151..a9f244a 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReplyController.kt @@ -4,6 +4,7 @@ import com.wafflestudio.interpark.pagination.CursorPageResponse import com.wafflestudio.interpark.pagination.CursorPageable import com.wafflestudio.interpark.review.service.ReplyService import com.wafflestudio.interpark.user.controller.UserDetailsImpl +import io.swagger.v3.oas.annotations.Operation import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* @@ -13,6 +14,10 @@ class ReplyController( private val replyService: ReplyService, ) { @GetMapping("/api/v1/me/reply") + @Operation( + summary = "본인의 댓글 조회", + description = "본인이 작성한 댓글들을 조회할 수 있습니다." + ) fun getRepliesByUser( @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity{ @@ -21,6 +26,10 @@ class ReplyController( } @GetMapping("/api/v1/review/{reviewId}/reply") + @Operation( + summary = "리뷰의 댓글 조회", + description = "특정 리뷰에 달린 댓글들을 조회할 수 있습니다." + ) fun getReplies( @PathVariable reviewId: String, ): ResponseEntity{ @@ -29,6 +38,10 @@ class ReplyController( } @GetMapping("/api/v2/review/{reviewId}/reply") + @Operation( + summary = "페이지네이션이 적용된 리뷰의 댓글 조회", + description = "특정 리뷰에 달린 댓글들을 조회할 수 있습니다." + ) fun getCursorReplies( @PathVariable reviewId: String, @RequestParam cursor: String?, @@ -39,6 +52,9 @@ class ReplyController( } @PostMapping("/api/v1/review/{reviewId}/reply") + @Operation( + summary = "댓글 작성", + ) fun createReply( @RequestBody request: CreateReplyRequest, @PathVariable reviewId: String, @@ -49,6 +65,9 @@ class ReplyController( } @PutMapping("/api/v1/reply/{replyId}") + @Operation( + summary = "댓글 수정", + ) fun editReply( @RequestBody request: EditReplyRequest, @PathVariable replyId: String, @@ -59,6 +78,9 @@ class ReplyController( } @DeleteMapping("/api/v1/reply/{replyId}") + @Operation( + summary = "댓글 삭제", + ) fun deleteReply( @PathVariable replyId: String, @AuthenticationPrincipal userDetails: UserDetailsImpl diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt index 42a112c..873a821 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/controller/ReviewController.kt @@ -6,6 +6,7 @@ import com.wafflestudio.interpark.review.* import com.wafflestudio.interpark.review.service.ReplyService import com.wafflestudio.interpark.review.service.ReviewService import com.wafflestudio.interpark.user.controller.UserDetailsImpl +import io.swagger.v3.oas.annotations.Operation import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* @@ -16,6 +17,10 @@ class ReviewController( ) { @GetMapping("/api/v1/me/review") + @Operation( + summary = "작성한 리뷰 조회", + description = "본인이 작성했던 리뷰들을 조회할 수 있습니다." + ) fun getMyReviews( @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity{ @@ -24,6 +29,10 @@ class ReviewController( } @GetMapping("/api/v1/performance/{performanceId}/review") + @Operation( + summary = "공연의 리뷰 조회", + description = "특정 공연에 달린 리뷰들을 조회할 수 있습니다." + ) fun getReviews( @PathVariable performanceId: String, ): ResponseEntity{ @@ -32,6 +41,10 @@ class ReviewController( } @GetMapping("/api/v2/performance/{performanceId}/review") + @Operation( + summary = "페이지네이션이 적용된 공연의 리뷰 조회", + description = "특정 공연에 달린 리뷰들을 조회할 수 있습니다." + ) fun getCursorReviews( @PathVariable performanceId: String, @RequestParam cursor: String?, @@ -42,6 +55,9 @@ class ReviewController( } @PostMapping("/api/v1/performance/{performanceId}/review") + @Operation( + summary = "리뷰 작성", + ) fun createReview( @RequestBody request: CreateReviewRequest, @PathVariable performanceId: String, @@ -52,6 +68,9 @@ class ReviewController( } @PutMapping("/api/v1/review/{reviewId}") + @Operation( + summary = "리뷰 수정", + ) fun editReview( @RequestBody request: EditReviewRequest, @PathVariable reviewId: String, @@ -62,6 +81,9 @@ class ReviewController( } @DeleteMapping("/api/v1/review/{reviewId}") + @Operation( + summary = "리뷰 삭제", + ) fun deleteReview( @PathVariable reviewId: String, @AuthenticationPrincipal userDetails: UserDetailsImpl @@ -71,6 +93,9 @@ class ReviewController( } @PostMapping("/api/v1/review/{reviewId}/like") + @Operation( + summary = "리뷰 공감", + ) fun likeReview( @PathVariable reviewId: String, @AuthenticationPrincipal userDetails: UserDetailsImpl @@ -80,6 +105,9 @@ class ReviewController( } @DeleteMapping("/api/v1/review/{reviewId}/like") + @Operation( + summary = "리뷰 공감 취소", + ) fun cancelLikeReview( @PathVariable reviewId: String, @AuthenticationPrincipal userDetails: UserDetailsImpl diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt index 213a6ad..40a7985 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/controller/SeatController.kt @@ -4,6 +4,7 @@ import com.wafflestudio.interpark.seat.service.SeatService import com.wafflestudio.interpark.user.AuthUser import com.wafflestudio.interpark.user.controller.User import com.wafflestudio.interpark.user.controller.UserDetailsImpl +import io.swagger.v3.oas.annotations.Operation import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.DeleteMapping @@ -19,6 +20,10 @@ class SeatController( private val seatService: SeatService, ) { @GetMapping("/api/v1/seat/{performanceEventId}/available") + @Operation( + summary = "예매가능한 좌석 조회", + description = "구체적인 공연이 주어졌을 때 그 공연에서 예매할 수 있는 좌석들을 알려줍니다." + ) fun getAvailableSeats( @PathVariable performanceEventId: String, ): ResponseEntity { @@ -27,6 +32,10 @@ class SeatController( } @PostMapping("/api/v1/reservation/reserve") + @Operation( + summary = "예매", + description = "좌석을 선택해서 예매할 수 있습니다. 예매에 실패하면 409가 반환됩니다." + ) fun reserveSeat( @RequestBody request: ReserveSeatRequest, @AuthenticationPrincipal userDetails: UserDetailsImpl @@ -36,6 +45,10 @@ class SeatController( } @GetMapping("/api/v1/me/reservation") + @Operation( + summary = "본인의 예매내역 조회", + description = "본인이 예매한 내역을 예매정보를 간략화 한 배열로 반환합니다.\n더 구체적인 정보가 필요하다면 /api/v1/reservation/detail/{reservationId}를 사용해야 합니다." + ) fun getMyReservations( @AuthenticationPrincipal userDetails: UserDetailsImpl ): ResponseEntity { @@ -44,6 +57,10 @@ class SeatController( } @GetMapping("/api/v1/reservation/detail/{reservationId}") + @Operation( + summary = "본인의 예매 자세히 보기", + description = "본인의 예매 중 하나를 선택해 자세한 정보를 받을 수 있습니다." + ) fun getReservedSeatDetail( @PathVariable reservationId: String, @AuthenticationPrincipal userDetails: UserDetailsImpl @@ -53,6 +70,10 @@ class SeatController( } @DeleteMapping("/api/v1/reservation/{reservationId}") + @Operation( + summary = "예매 취소", + description = "본인의 예매 중 하나를 선택해 취소할 수 있습니다." + ) fun cancelReservedSeat( @PathVariable reservationId: String, @AuthenticationPrincipal userDetails: UserDetailsImpl From 4326b376f8a2edf84b34e9679a255356b59251d7 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sun, 2 Feb 2025 01:32:06 +0900 Subject: [PATCH 142/162] feat: Swagger summary for UserController --- .../interpark/user/controller/UserController.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt index 65e31e2..ed56a7e 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/controller/UserController.kt @@ -103,6 +103,10 @@ class UserController( } @PostMapping("/api/v1/local/signin") + @Operation( + summary = "로그인", + description = "로그인을 처리하고 성공하면 accessToken을 반환하고, refreshToken을 쿠키에 저장한다." + ) fun signin( @RequestBody request: SignInRequest, response: HttpServletResponse, @@ -122,6 +126,9 @@ class UserController( } @GetMapping("/api/v1/users/me") + @Operation( + summary = "내 정보 확인", + ) fun me( @AuthenticationPrincipal userDetails: UserDetailsImpl, ): ResponseEntity { @@ -137,6 +144,10 @@ class UserController( } @PostMapping("/api/v1/auth/signout") + @Operation( + summary = "로그아웃", + description = "로그아웃을 처리하고 refreshToken을 사용할 수 없도록 한다." + ) fun signout( @CookieValue(value = "refreshToken", required = false) refreshToken: String?, ): ResponseEntity { @@ -148,6 +159,10 @@ class UserController( } @PostMapping("/api/v1/auth/refresh_token") + @Operation( + summary = "토큰 재발행", + description = "refreshToken이 유효할 때 accessToken과 refreshToken을 다시 발급한다." + ) fun refreshToken( @CookieValue(value = "refreshToken", required = false) refreshToken: String?, response: HttpServletResponse, From 65cd2a5a54628bef9d65e9e2806b4c9c6d6783b0 Mon Sep 17 00:00:00 2001 From: grantzile Date: Sun, 2 Feb 2025 09:27:40 +0900 Subject: [PATCH 143/162] CI: remove pull request trigger --- .github/workflows/build-test.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index 642218f..8ecba51 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -5,10 +5,6 @@ on: branches: - main - '*' - pull_request: - branches: - - main - - release-dev jobs: deploy: From 4aa64714a741ef192c67a3fe70b18a26b48247bb Mon Sep 17 00:00:00 2001 From: grantzile Date: Sun, 2 Feb 2025 09:34:51 +0900 Subject: [PATCH 144/162] CD: add --build on rerun script --- .github/workflows/build-test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index 8ecba51..71dd4a8 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -100,4 +100,4 @@ jobs: key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} script: | echo "Starting containers..." - docker compose up -d --no-deps myapp + docker compose up -d --no-deps myapp --build From 76c71417f01147fa3fc71f5ab449f5eb6cb18ef4 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sun, 2 Feb 2025 11:35:05 +0900 Subject: [PATCH 145/162] read me save --- README.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4438db8..22e399d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,6 @@ > 주요 기능은 **사용자 인증/인가(JWT)**, **게시글 CRUD** 등입니다. > 와플티켓 안드로이드 앱의 백엔드 서버 역할을 합니다. --- -[와플 티켓 벡엔드 서버 API 문서](http://15.164.225.121/swagger-ui/index.html#/) ## 목차 @@ -15,13 +14,12 @@ 2. [기술 스택](#기술-스택-tech-stack) 3. [주요 기능](#주요-기능-features) 4. [설치 및 실행](#설치-및-실행-getting-started) -5. [환경 변수 / 설정](#환경-변수--설정-environment-variables) -6. [DB 구조](#db-구조-database-schema) -7. [API 명세](#api-명세-api-documentation) -8. [테스트](#테스트-testing) -9. [배포](#배포-deployment) -10. [라이선스](#라이선스-license) -11. [기여](#기여-contributing) +5. [DB 구조](#db-구조-database-schema) +6. [API 명세](#api-명세-api-documentation) +7. [테스트](#테스트-testing) +8. [배포](#배포-deployment) +9. [라이선스](#라이선스-license) +10. [기여](#기여-contributing) --- @@ -110,4 +108,9 @@ ```bash ./gradlew bootRun ``` - - 기본 포트: `http://localhost:8080` \ No newline at end of file + - 기본 포트: `http://localhost:8080` + +## DB 구조 (Database schema) + +## API 명세 (API documentation) +[와플 티켓 벡엔드 서버 API 문서](http://15.164.225.121/swagger-ui/index.html#/) \ No newline at end of file From 72d47473e6993ba3c27edacc6e139c2450efd670 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus <153504213+ChungPlusPlus@users.noreply.github.com> Date: Sun, 2 Feb 2025 11:44:45 +0900 Subject: [PATCH 146/162] =?UTF-8?q?feat:=20Cascade=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/PerformanceEntity.kt | 8 ++++++++ .../persistence/PerformanceEventEntity.kt | 6 ++++++ .../persistence/PerformanceHallEntity.kt | 8 ++++++++ .../review/persistence/ReviewEntity.kt | 3 +++ .../interpark/seat/persistence/SeatEntity.kt | 4 ++++ .../interpark/user/persistence/UserEntity.kt | 18 ++++++++++++++++++ .../user/persistence/UserIdentityEntity.kt | 4 ++++ 7 files changed, 51 insertions(+) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt index 37321e6..c4625ff 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEntity.kt @@ -1,5 +1,7 @@ package com.wafflestudio.interpark.performance.persistence +import com.wafflestudio.interpark.review.persistence.ReplyEntity +import com.wafflestudio.interpark.review.persistence.ReviewEntity import jakarta.persistence.* import java.time.Instant import java.time.LocalDate @@ -26,6 +28,12 @@ class PerformanceEntity( @Column(name = "backdrop_image_uri", nullable = false) val backdropImageUri: String, + + @OneToMany(mappedBy = "performance", cascade = [CascadeType.ALL], orphanRemoval = true) + var reviews: MutableSet = mutableSetOf(), + + @OneToMany(mappedBy = "performance", cascade = [CascadeType.ALL], orphanRemoval = true) + var performanceEvents: MutableSet = mutableSetOf(), ) enum class PerformanceCategory { diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventEntity.kt index fae2427..fab7a35 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceEventEntity.kt @@ -1,5 +1,8 @@ package com.wafflestudio.interpark.performance.persistence +import com.wafflestudio.interpark.review.persistence.ReplyEntity +import com.wafflestudio.interpark.review.persistence.ReviewEntity +import com.wafflestudio.interpark.seat.persistence.ReservationEntity import jakarta.persistence.* import java.time.Instant @@ -23,4 +26,7 @@ data class PerformanceEventEntity( @Column(name = "end_at", nullable = false) val endAt: Instant, + + @OneToMany(mappedBy = "performanceEvent", cascade = [CascadeType.ALL], orphanRemoval = true) + var reservations: MutableSet = mutableSetOf(), ) diff --git a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallEntity.kt index a0c73df..65aa180 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/performance/persistence/PerformanceHallEntity.kt @@ -1,5 +1,7 @@ package com.wafflestudio.interpark.performance.persistence +import com.wafflestudio.interpark.review.persistence.ReviewEntity +import com.wafflestudio.interpark.seat.persistence.SeatEntity import jakarta.persistence.* import java.time.Instant @@ -18,4 +20,10 @@ class PerformanceHallEntity( @Column(name = "max_audience", nullable = false) val maxAudience: Int, + + @OneToMany(mappedBy = "performanceHall", cascade = [CascadeType.ALL], orphanRemoval = true) + var performanceEvents: MutableSet = mutableSetOf(), + + @OneToMany(mappedBy = "performanceHall", cascade = [CascadeType.ALL], orphanRemoval = true) + var seats: MutableSet = mutableSetOf(), ) diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt index bf42588..d494126 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt @@ -37,4 +37,7 @@ class ReviewEntity( @OneToMany(mappedBy = "review") var reviewLikes: List = emptyList(), + + @OneToMany(mappedBy = "review", cascade = [CascadeType.ALL], orphanRemoval = true) + var replies: MutableSet = mutableSetOf(), ) diff --git a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt index 7cfe9df..a1f3478 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/seat/persistence/SeatEntity.kt @@ -1,6 +1,7 @@ package com.wafflestudio.interpark.seat.persistence import com.wafflestudio.interpark.performance.persistence.PerformanceHallEntity +import com.wafflestudio.interpark.review.persistence.ReviewEntity import jakarta.persistence.* @Entity @@ -16,4 +17,7 @@ class SeatEntity( val seatNumber: Pair, @Column(name = "price") var price: Int = 10000, + + @OneToMany(mappedBy = "seat", cascade = [CascadeType.ALL], orphanRemoval = true) + var reservations: MutableSet = mutableSetOf(), ) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserEntity.kt index 9ddccb0..3f68fd3 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserEntity.kt @@ -1,10 +1,16 @@ package com.wafflestudio.interpark.user.persistence +import com.wafflestudio.interpark.review.persistence.ReplyEntity +import com.wafflestudio.interpark.review.persistence.ReviewEntity +import com.wafflestudio.interpark.seat.persistence.ReservationEntity +import jakarta.persistence.CascadeType import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id +import jakarta.persistence.OneToMany +import jakarta.persistence.OneToOne @Entity class UserEntity( @@ -21,4 +27,16 @@ class UserEntity( val email: String, @Column(name = "address", nullable = true) val address: String? = null, + + @OneToMany(mappedBy = "author", cascade = [CascadeType.ALL], orphanRemoval = true) + var reviews: MutableSet = mutableSetOf(), + + @OneToMany(mappedBy = "author", cascade = [CascadeType.ALL], orphanRemoval = true) + var replies: MutableSet = mutableSetOf(), + + @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true) + var reservations: MutableSet = mutableSetOf(), + + @OneToOne(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true) + var userIdentity: UserIdentityEntity? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt index 5421ad4..68383de 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/persistence/UserIdentityEntity.kt @@ -1,5 +1,6 @@ package com.wafflestudio.interpark.user.persistence +import com.wafflestudio.interpark.review.persistence.ReviewEntity import jakarta.persistence.CascadeType import jakarta.persistence.Column import jakarta.persistence.Entity @@ -24,8 +25,11 @@ class UserIdentityEntity( var role: UserRole = UserRole.USER, @Column(name = "hashed_password", nullable = false) val hashedPassword: String, + // @OneToMany(mappedBy = "userIdentity", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) // val socialAccounts: MutableList = mutableListOf(), + @OneToMany(mappedBy = "userIdentity", cascade = [CascadeType.ALL], orphanRemoval = true) + var socialAccounts: MutableSet = mutableSetOf(), ) enum class UserRole : GrantedAuthority { From 4b7c930dbc603c35a8f3faa76ea97687473f510f Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sun, 2 Feb 2025 12:03:34 +0900 Subject: [PATCH 147/162] readme save --- README.md | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ec9e49c..6cf02f1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ > 이 프로젝트는 **Spring Boot**를 사용한 **RESTful API 서버**이며, > 주요 기능은 **사용자 인증/인가(JWT)**, **게시글 CRUD** 등입니다. -> 와플티켓 안드로이드 앱의 백엔드 서버 역할을 합니다. +> 와플티켓 안드로이드 앱의 백엔드 서버 역할을 합니다. +> [와플티켓 안드로이드 앱 깃헙 바로가기](https://github.com/wafflestudio/22-5-team4-android) --- ## 목차 @@ -17,10 +18,8 @@ 5. [환경 변수 / 설정](#환경-변수--설정-environment-variables) 6. [DB 구조](#db-구조-database-schema) 7. [API 명세](#api-명세-api-documentation) -8. [테스트](#테스트-testing) -9. [배포](#배포-deployment) -10. [라이선스](#라이선스-license) -11. [기여](#기여-contributing) +8. [배포](#배포-deployment) +9. [기여](#기여-contributing) --- @@ -130,7 +129,7 @@ cursor는 id와 정렬기준값을 기반으로 서버에서 생성하였습니 make 명령어 실행 시 빌드와 docker를 이용한 배포가 같이 이루어집니다 접속 도메인: `http://localhost` -## 환경 변수 / 설정 +## 환경 변수 / 설정 (Environment Variables) 로컬 배포시 .env 파일을 아래와 같이 설정합니다 ```aiignore SPRING_DATASOURCE_URL: "jdbc:mysql://mysql-db:3306/testdb" @@ -145,11 +144,15 @@ NAVER_CLIENT_SECRET: ??? JWT_SECRET_KEY는 32자 이상의 적당한 문자열을 사용하면 됩니다 --- -## DB 구조 +## DB 구조 (Database Schema) ### 엔티티 구조 ![EntityRelationDiagram.png](EntityRelationDiagram.png) ## API 명세 [와플 티켓 벡엔드 서버 API 문서](http://15.164.225.121/swagger-ui/index.html#/) -## 배포 deployment -## 라이선스 license -## 기여 contributing \ No newline at end of file +## 배포 (deployment) + +## 기여 (contributing) + +- [@Grantzile](https://github.com/Grantzile) +- [@ChungPlusPlus](https://github.com/ChungPlusPlus) +- [@kdh8156](https://github.com/kdh8156) \ No newline at end of file From 3a56e81db942cd665acbf84a85abd858d0eaed17 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sun, 2 Feb 2025 12:57:24 +0900 Subject: [PATCH 148/162] readme save --- README.md | 42 +++++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 6cf02f1..0b98645 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,16 @@ # 22-5-team4-server -# 🧇 WaffleTicket 🎫 (Interpark Ticket clone) - +

+

🧇 WaffleTicket 🎫 (Interpark Ticket clone)

+
+
> 이 프로젝트는 **Spring Boot**를 사용한 **RESTful API 서버**이며, -> 주요 기능은 **사용자 인증/인가(JWT)**, **게시글 CRUD** 등입니다. -> 와플티켓 안드로이드 앱의 백엔드 서버 역할을 합니다. -> [와플티켓 안드로이드 앱 깃헙 바로가기](https://github.com/wafflestudio/22-5-team4-android) +> 주요 기능은 **공연 검색 및 예매**, **게시글 CRUD** 등입니다. +> 와플티켓 안드로이드 앱의 백엔드 서버 역할을 합니다. + +[와플티켓 안드로이드 앱 바로가기](https://github.com/wafflestudio/22-5-team4-android) + --- ## 목차 @@ -25,9 +29,9 @@ ## 프로젝트 개요 (Project Overview) -**와플티켓 안드로이드 앱 백엔드 서버**는 공연 정보를 등록하고, 사용자가 티켓을 예매/취소할 수 있는 기능을 제공합니다. +**와플티켓 안드로이드 앱 백엔드 서버**는 공연 정보 조회 및 등록, 리뷰 작성, 티켓 예매/취소 기능을 제공합니다. - **목적**: 오프라인 공연의 티켓 예매 과정을 온라인 서비스로 전환 -- **주요 특징**: 공연 일정/좌석/가격 관리, 예매/결제/취소, 소셜 로그인 등 +- **주요 특징**: 공연 일정/좌석/가격 관리, 예매/결제/취소, 리뷰 작성, 소셜 로그인 등 - **추가 내용**: 관리자와 일반 사용자의 접근 권한을 구분하며, 앱에서 직접 공연을 추가할 수 있도록 합니다 --- @@ -73,14 +77,22 @@ - 대규모 트래픽 대비 확장성 확보 --- ## 주요 기능 구현 방식 -### 액세스 토큰 발급, 재발급 +- ### 액세스 토큰 발급, 재발급 기본적인 유저 인증은 Jwt를 이용해 처리했습니다 accessToken의 만료시간은 15분으로 짧게 두고, refreshToken을 사용해 재발급할 수 있도록 하였습니다. 이를 통해 토큰 탈취의 위험성을 줄이고자 했습니다. refreshToken은 자동으로 쿠키로 전달됩니다. -### 페이지네이션 구현 +- ### 소셜 로그인 +안드로이드 클라이언트에서 **OAuth2 소셜 로그인**을 수행하면, 인증 서버(카카오, 네이버 등)로부터 **액세스 토큰**을 발급받습니다. +이후, 클라이언트는 해당 액세스 토큰을 백엔드 서버로 전달하며, 서버는 이를 활용하여 소셜 인증 서버에서 **사용자 정보를 조회**합니다. + +조회한 사용자 정보는 기존 로컬 계정과의 연동 여부를 확인하는 데 사용되며, **연동 여부**에 따라 다음과 같은 처리 과정이 진행됩니다. +- 연동된 계정 존재 → 기존 사용자로 로그인 처리 및 응답 반환 +- 연동된 계정 없음 → 404 에러 반환 (연동 가능하도록 추가 정보 제공) → `/api/v1/social/link`을 통해 연동 요청 + +- ### 페이지네이션 구현 무한스크롤을 구현하기 위한 페이지네이션을 구현했습니다 no offset 방식으로 구현을 했고, 이를 위해 마지막으로 반환한 정보를 나타내는 엔티티를 가리키는 cursor를 사용했습니다 @@ -93,7 +105,7 @@ cursor는 id와 정렬기준값을 기반으로 서버에서 생성하였습니 ``` 위 서비스들에 페이지네이션을 적용했습니다 -### 좌석 & 예매 구현 +- ### 좌석 & 예매 구현 공연의 좌석들을 조회하고, 해당 공연의 예매 정보를 조회하여 아직 예매되지 않은 예매 가능한 좌석을 반환하도록 만들었습니다. 예매정보를 저장하는 db에서 같은 좌석의 데이터가 들어올 수 없도록 uniqueConstraints를 설정했습니다 . @@ -145,12 +157,20 @@ JWT_SECRET_KEY는 32자 이상의 적당한 문자열을 사용하면 됩니다 --- ## DB 구조 (Database Schema) + +--- ### 엔티티 구조 ![EntityRelationDiagram.png](EntityRelationDiagram.png) + +--- ## API 명세 -[와플 티켓 벡엔드 서버 API 문서](http://15.164.225.121/swagger-ui/index.html#/) +### [와플 티켓 벡엔드 서버 API 문서](http://15.164.225.121/swagger-ui/index.html#/) +위 링크가 만료되었거나 접근이 불가능한 경우, 프로젝트를 직접 빌드 및 실행한 후 [링크](http://localhost/swagger-ui/index.html)에서 API 문서를 확인할 수 있습니다. + +--- ## 배포 (deployment) +--- ## 기여 (contributing) - [@Grantzile](https://github.com/Grantzile) From a972fc0ff4e75a8dbe26f816794bd6c22bdbe81f Mon Sep 17 00:00:00 2001 From: grantzile Date: Sun, 2 Feb 2025 13:05:23 +0900 Subject: [PATCH 149/162] fix: README.md --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 0b98645..10430b6 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,34 @@ cursor는 id와 정렬기준값을 기반으로 서버에서 생성하였습니 같은 좌석에 여러 예매 요청이 들어왔을 때 하나만 통과하는 것을 테스트로 확인했습니다. + +### 좌석 & 예매 최적화 (origin/add-redis-cache) +공연 좌석을 예약할 때 unique한 키로 redis에 락을 걸게 됩니다. +삭제될 때는 동일한 키로 락을 해제합니다. +- 동시성 문제를 해결합니다. 다른 예약 로직이 돌아가고 있을 때 다른 서버에서 동일한 요청을 처리하지 않습니다. + +공연 좌석 정보를 전달하는 메서드에 lookaside 캐싱을 추가했습니다. +- GET 요청이고, 남은 좌석 전부를 돌려주는 것이므로 캐싱이 가능합니다. +- 예매/취소가 성공하면 CacheEvict를 통해 캐시를 무효화합니다. + +### Gatling으로 부하테스트 수행 +Gatling을 통해 1k 연결 / 100건의 반복 예매 요청으로 테스트했습니다. +- 가장 대기시간이 긴 것은 회원가입과 로그인입니다. + - Jwt에서 작동하는 해시 검증이 부하를 주고 있었습니다. + - 해당 기능은 rate limit을 통해 DoS를 막을 예정입니다. +- 공연 좌석 정보를 전달하는 메서드의 대기시간이 50%까지 감소한 것을 확인했습니다. + + +### (개발 중) NGINX -> RabbitMQ 트래픽 리다이렉션 +Gatling 부하 테스트에서 connection이 많아지면 성능이 급격하게 떨어지는 점을 확인했습니다. +따라서 cache로 빠르게 처리될 수 없는 예매 요청을 RabbitMQ로 보내고, 기존의 컨트롤러에서 처리하던 예매 기능은 RabbitMQ로부터 요청을 가져와 반환하는 형식으로 변경 중입니다. +- 이 경우 RabbitMQ에서 메시지를 받아와 처리하기만 하면 되므로, MSA를 적용하기 매우 편리합니다. +- 전체 서버는 매우 무겁습니다 - 예매요청 처리를 위해서는 매우 가벼운 pod들만 배포해도 문제없습니다. + - MSA 구조를 이용하면 저비용으로도 높은 순간 트래픽을 견딜 수 있을 것입니다. + +### (개발 중) SonarLint +UserIdentityEntity와 같은 민감 객체들에 대한 접근 통제를 위해 SonarLint를 적용할 예정입니다. + --- ## 설치 및 실행 (Getting Started) From f84a7f0fc531b2252da72884b16793108ae412f6 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sun, 2 Feb 2025 13:15:37 +0900 Subject: [PATCH 150/162] readme save --- README.md | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0b98645..7cf7c9e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@

🧇 WaffleTicket 🎫 (Interpark Ticket clone)

-
> 이 프로젝트는 **Spring Boot**를 사용한 **RESTful API 서버**이며, > 주요 기능은 **공연 검색 및 예매**, **게시글 CRUD** 등입니다. @@ -11,8 +10,6 @@ [와플티켓 안드로이드 앱 바로가기](https://github.com/wafflestudio/22-5-team4-android) ---- - ## 목차 1. [프로젝트 개요](#프로젝트-개요-project-overview) @@ -34,7 +31,7 @@ - **주요 특징**: 공연 일정/좌석/가격 관리, 예매/결제/취소, 리뷰 작성, 소셜 로그인 등 - **추가 내용**: 관리자와 일반 사용자의 접근 권한을 구분하며, 앱에서 직접 공연을 추가할 수 있도록 합니다 ---- + ## 기술 스택 (Tech Stack) @@ -55,7 +52,7 @@ | Auth | JWT, Social(OAuth2) | Access/Refresh Token 발급, 카카오/네이버 로그인 | | Infra | AWS (EC2, RDS), Docker | 개발/테스트/운영 환경 분리 | ---- + ## 주요 기능 (Features) @@ -75,7 +72,7 @@ 5. **캐싱 / 성능 최적화** - 공연 목록, 좌석 정보 캐싱 - 대규모 트래픽 대비 확장성 확보 ---- + ## 주요 기능 구현 방식 - ### 액세스 토큰 발급, 재발급 기본적인 유저 인증은 Jwt를 이용해 처리했습니다 @@ -112,7 +109,7 @@ cursor는 id와 정렬기준값을 기반으로 서버에서 생성하였습니 같은 좌석에 여러 예매 요청이 들어왔을 때 하나만 통과하는 것을 테스트로 확인했습니다. ---- + ## 설치 및 실행 (Getting Started) ### 사전 요구사항 (Prerequisites) @@ -155,22 +152,22 @@ NAVER_CLIENT_SECRET: ??? ``` JWT_SECRET_KEY는 32자 이상의 적당한 문자열을 사용하면 됩니다 ---- + ## DB 구조 (Database Schema) ---- + ### 엔티티 구조 ![EntityRelationDiagram.png](EntityRelationDiagram.png) ---- + ## API 명세 ### [와플 티켓 벡엔드 서버 API 문서](http://15.164.225.121/swagger-ui/index.html#/) 위 링크가 만료되었거나 접근이 불가능한 경우, 프로젝트를 직접 빌드 및 실행한 후 [링크](http://localhost/swagger-ui/index.html)에서 API 문서를 확인할 수 있습니다. ---- + ## 배포 (deployment) ---- + ## 기여 (contributing) - [@Grantzile](https://github.com/Grantzile) From 06f9572e3a9cd57c67f5bf4dc7c6c947b73c844c Mon Sep 17 00:00:00 2001 From: grantzile Date: Sun, 2 Feb 2025 13:23:02 +0900 Subject: [PATCH 151/162] feat: add stack --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 10430b6..f92e6f1 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,24 @@ - **DB(Database)**: - **빌드/의존성 관리**: - **인증(Authentication)**: JWT (), OAuth 2.0( ) +- **부하 테스트**: - **기타**: - Docker & Docker Compose - Swagger (Springdoc) for API 문서화 - AWS (EC2, RDS) 배포 가능 +- **도입 중** + - Web proxy: + - MessageQueue: + +- **적용 검토 중** + - SonarLint: ![SonarLint Badge](https://img.shields.io/badge/SonarLint-CB2029?logo=sonarlint&logoColor=fff&style=flat) + - Orchestration: ![Kubernetes Badge](https://img.shields.io/badge/Kubernetes-326CE5?logo=kubernetes&logoColor=fff&style=flat) + - GitOps: ![ArgoCD Badge](https://img.shields.io/badge/ArgoCD-EF7B4D?logo=argo&logoColor=fff&style=flat) + - MSA: + - ![Phoenix Framework Badge](https://img.shields.io/badge/Phoenix%20Framework-FD4F00?logo=phoenixframework&logoColor=fff&style=flat) + - ![Actix Badge](https://img.shields.io/badge/Actix-000?logo=actix&logoColor=fff&style=flat) + | 구분 | 기술 | 비고 | |--------------|--------------------------------|--------------------------------------| | Backend | Spring Boot 3 (Kotlin) | Java 23 기반 | From cb03856e53a15d42c5bdbe11df43dbfebe693054 Mon Sep 17 00:00:00 2001 From: grantzile Date: Sun, 2 Feb 2025 13:24:16 +0900 Subject: [PATCH 152/162] feat: add language --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f92e6f1..54fc73f 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ ## 기술 스택 (Tech Stack) -- **언어(Language)**: +- **언어(Language)**: - **프레임워크(Framework)**: - **DB(Database)**: - **빌드/의존성 관리**: From ad34b19aa333223eff530a9ac775923fb9639938 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sun, 2 Feb 2025 13:25:59 +0900 Subject: [PATCH 153/162] readme save --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 879c105..ea5dca7 100644 --- a/README.md +++ b/README.md @@ -72,14 +72,14 @@ - 공연 목록, 좌석 정보 캐싱 - 대규모 트래픽 대비 확장성 확보 ## 주요 기능 구현 방식 -- ### 액세스 토큰 발급, 재발급 +### - 액세스 토큰 발급, 재발급 기본적인 유저 인증은 Jwt를 이용해 처리했습니다 accessToken의 만료시간은 15분으로 짧게 두고, refreshToken을 사용해 재발급할 수 있도록 하였습니다. 이를 통해 토큰 탈취의 위험성을 줄이고자 했습니다. refreshToken은 자동으로 쿠키로 전달됩니다. -- ### 소셜 로그인 +### - 소셜 로그인 안드로이드 클라이언트에서 **OAuth2 소셜 로그인**을 수행하면, 인증 서버(카카오, 네이버 등)로부터 **액세스 토큰**을 발급받습니다. 이후, 클라이언트는 해당 액세스 토큰을 백엔드 서버로 전달하며, 서버는 이를 활용하여 소셜 인증 서버에서 **사용자 정보를 조회**합니다. @@ -87,7 +87,7 @@ refreshToken은 자동으로 쿠키로 전달됩니다. - 연동된 계정 존재 → 기존 사용자로 로그인 처리 및 응답 반환 - 연동된 계정 없음 → 404 에러 반환 (연동 가능하도록 추가 정보 제공) → `/api/v1/social/link`을 통해 연동 요청 -- ### 페이지네이션 구현 +### - 페이지네이션 구현 무한스크롤을 구현하기 위한 페이지네이션을 구현했습니다 no offset 방식으로 구현을 했고, 이를 위해 마지막으로 반환한 정보를 나타내는 엔티티를 가리키는 cursor를 사용했습니다 @@ -100,7 +100,7 @@ cursor는 id와 정렬기준값을 기반으로 서버에서 생성하였습니 ``` 위 서비스들에 페이지네이션을 적용했습니다 -- ### 좌석 & 예매 구현 +### - 좌석 & 예매 구현 공연의 좌석들을 조회하고, 해당 공연의 예매 정보를 조회하여 아직 예매되지 않은 예매 가능한 좌석을 반환하도록 만들었습니다. 예매정보를 저장하는 db에서 같은 좌석의 데이터가 들어올 수 없도록 uniqueConstraints를 설정했습니다 . @@ -108,7 +108,7 @@ cursor는 id와 정렬기준값을 기반으로 서버에서 생성하였습니 같은 좌석에 여러 예매 요청이 들어왔을 때 하나만 통과하는 것을 테스트로 확인했습니다. -### 좌석 & 예매 최적화 (origin/add-redis-cache) +### - 좌석 & 예매 최적화 (origin/add-redis-cache) 공연 좌석을 예약할 때 unique한 키로 redis에 락을 걸게 됩니다. 삭제될 때는 동일한 키로 락을 해제합니다. - 동시성 문제를 해결합니다. 다른 예약 로직이 돌아가고 있을 때 다른 서버에서 동일한 요청을 처리하지 않습니다. @@ -117,7 +117,7 @@ cursor는 id와 정렬기준값을 기반으로 서버에서 생성하였습니 - GET 요청이고, 남은 좌석 전부를 돌려주는 것이므로 캐싱이 가능합니다. - 예매/취소가 성공하면 CacheEvict를 통해 캐시를 무효화합니다. -### Gatling으로 부하테스트 수행 +### - Gatling으로 부하테스트 수행 Gatling을 통해 1k 연결 / 100건의 반복 예매 요청으로 테스트했습니다. - 가장 대기시간이 긴 것은 회원가입과 로그인입니다. - Jwt에서 작동하는 해시 검증이 부하를 주고 있었습니다. @@ -127,7 +127,7 @@ Gatling을 통해 1k 연결 / 100건의 반복 예매 요청으로 테스트했 ### (개발 중) NGINX -> RabbitMQ 트래픽 리다이렉션 Gatling 부하 테스트에서 connection이 많아지면 성능이 급격하게 떨어지는 점을 확인했습니다. -따라서 cache로 빠르게 처리될 수 없는 예매 요청을 RabbitMQ로 보내고, 기존의 컨트롤러에서 처리하던 예매 기능은 RabbitMQ로부터 요청을 가져와 반환하는 형식으로 변경 중입니다. +따라서 cache로 빠르게 처리될 수 없는 예매 요청을 RabbitMQ로 보내고, 기존의 컨트롤러에서 처리하던 예매 기능은 RabbitMQ로부터 요청을 가져와 반환하는 형식으로 변경 중입니다. - 이 경우 RabbitMQ에서 메시지를 받아와 처리하기만 하면 되므로, MSA를 적용하기 매우 편리합니다. - 전체 서버는 매우 무겁습니다 - 예매요청 처리를 위해서는 매우 가벼운 pod들만 배포해도 문제없습니다. - MSA 구조를 이용하면 저비용으로도 높은 순간 트래픽을 견딜 수 있을 것입니다. From 4af3d7355a166b120833cede6d1623aab8216c82 Mon Sep 17 00:00:00 2001 From: grantzile Date: Sun, 2 Feb 2025 13:26:53 +0900 Subject: [PATCH 154/162] feat: add redis --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 54fc73f..691de0d 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ - AWS (EC2, RDS) 배포 가능 - **도입 중** + - - Web proxy: - MessageQueue: From 71f8dd6b50e72cc7371070729a497f19820fc590 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sun, 2 Feb 2025 13:29:13 +0900 Subject: [PATCH 155/162] remove --- lines --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e8d6b83..13fb9e7 100644 --- a/README.md +++ b/README.md @@ -35,16 +35,16 @@ ## 기술 스택 (Tech Stack) -- **언어(Language)**: -- **프레임워크(Framework)**: -- **DB(Database)**: -- **빌드/의존성 관리**: -- **인증(Authentication)**: JWT (), OAuth 2.0( ) +- **언어(Language)**: +- **프레임워크(Framework)**: +- **DB(Database)**: +- **빌드/의존성 관리**: +- **인증(Authentication)**: JWT (), OAuth 2.0( ) - **부하 테스트**: - **기타**: - - Docker & Docker Compose - - Swagger (Springdoc) for API 문서화 - - AWS (EC2, RDS) 배포 가능 + - Docker & Docker Compose + - Swagger (Springdoc) for API 문서화 + - AWS (EC2, RDS) 배포 가능 - **도입 중** - Web proxy: From f5bd7d43fd6c1a9e19623d176cd5e84c1c4af9f1 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sun, 2 Feb 2025 13:55:30 +0900 Subject: [PATCH 156/162] add JWT logo --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 362c666..e1d7454 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ - **프레임워크(Framework)**: - **DB(Database)**: - **빌드/의존성 관리**: -- **인증(Authentication)**: JWT (), OAuth 2.0( ) +- **인증(Authentication)**: JWT ( ), OAuth 2.0( ) - **부하 테스트**: - **기타**: - Docker & Docker Compose @@ -199,7 +199,7 @@ JWT_SECRET_KEY는 32자 이상의 적당한 문자열을 사용하면 됩니다 ![EntityRelationDiagram.png](EntityRelationDiagram.png) -## API 명세 +## API 명세 (API Documentation) ### [와플 티켓 벡엔드 서버 API 문서](http://15.164.225.121/swagger-ui/index.html#/) 위 링크가 만료되었거나 접근이 불가능한 경우, 프로젝트를 직접 빌드 및 실행한 후 [링크](http://localhost/swagger-ui/index.html)에서 API 문서를 확인할 수 있습니다. From 0c2bf0db756233ac257d4d0c4a2c8c54bc260bd6 Mon Sep 17 00:00:00 2001 From: Dohyeon Kim Date: Sun, 2 Feb 2025 13:57:33 +0900 Subject: [PATCH 157/162] add empty lines --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e1d7454..8a34164 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # 22-5-team4-server
-

🧇 WaffleTicket 🎫 (Interpark Ticket clone)

+

+

🧇 WaffleTicket 🎫 (Interpark Ticket clone)

+

> 이 프로젝트는 **Spring Boot**를 사용한 **RESTful API 서버**이며, From b2c630bad44441b08bb1ce8649ba7656cda48f88 Mon Sep 17 00:00:00 2001 From: DoHyeon Kim Date: Sun, 2 Feb 2025 14:01:06 +0900 Subject: [PATCH 158/162] add empty lines --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8a34164..8d8bec6 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # 22-5-team4-server +

🧇 WaffleTicket 🎫 (Interpark Ticket clone)

+

> 이 프로젝트는 **Spring Boot**를 사용한 **RESTful API 서버**이며, > 주요 기능은 **공연 검색 및 예매**, **게시글 CRUD** 등입니다. @@ -213,4 +215,4 @@ JWT_SECRET_KEY는 32자 이상의 적당한 문자열을 사용하면 됩니다 - [@Grantzile](https://github.com/Grantzile) - [@ChungPlusPlus](https://github.com/ChungPlusPlus) -- [@kdh8156](https://github.com/kdh8156) \ No newline at end of file +- [@kdh8156](https://github.com/kdh8156) From 801de92af2f013316dc1dc08bf20674e2eae2880 Mon Sep 17 00:00:00 2001 From: grantzile Date: Sun, 2 Feb 2025 14:02:39 +0900 Subject: [PATCH 159/162] fix(README.md): add simple deploy descriptions --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8d8bec6..6ffec44 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,13 @@ JWT_SECRET_KEY는 32자 이상의 적당한 문자열을 사용하면 됩니다 ## 배포 (deployment) - +- Github workflow를 통해 자동으로 이루어집니다. + - 현재 단일 EC2에 배포하는 형식으로 이루어져 있으며, 권한이 제한된 배포용 계정으로 접속해 배포합니다. + - IP 제어가 필요하나 현재는 적용되어 있지 않습니다. + - 추후 IAM 인증을 통해 Workflow의 EC2 SSH 접근 권한을 일시적으로 허용하고 배포한 뒤 다시 롤백하는 방식으로 변경 예정 + - 추후 MSA 구조 적용 시 ArgoCD를 이용할 예정 + - Fargate는 k8s 노드 롤링 등으로 인한 예상치 못한 다운타임을 생각하지 않아도 되나, 현재 서비스에 stateful하게 동작하는 메커니즘이 없어 문제가 되지 않는다고 판단함. + - 결정적으로, stateless하게 동작한다면 k8s 노드 또한 노드 롤링 시 graceful shutdown을 지원하기에 문제가 되지 않습니다. ## 기여 (contributing) From 02fbca56af47ef040fc3ae70209ee4cc8767facb Mon Sep 17 00:00:00 2001 From: ChungPlusPlus <153504213+ChungPlusPlus@users.noreply.github.com> Date: Sun, 2 Feb 2025 14:04:05 +0900 Subject: [PATCH 160/162] =?UTF-8?q?index=20=EC=B6=94=EA=B0=80=20(#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Cascade 설정 * feat: index 추가중 * feat: 조회 성능 향상을 위한 index 추가 --- .../wafflestudio/interpark/review/persistence/ReplyEntity.kt | 5 ++++- .../interpark/review/persistence/ReviewEntity.kt | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyEntity.kt index 2790c8f..33db490 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReplyEntity.kt @@ -5,7 +5,10 @@ import jakarta.persistence.* import java.time.Instant @Entity -@Table(name = "reply") +@Table( + name = "reply", + indexes = [jakarta.persistence.Index(name = "idx__createdAt", columnList = "created_at")] +) data class ReplyEntity( @Id @GeneratedValue(strategy = GenerationType.UUID) diff --git a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt index d494126..32c2eda 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/review/persistence/ReviewEntity.kt @@ -6,7 +6,10 @@ import jakarta.persistence.* import java.time.Instant @Entity -@Table(name = "reviews") +@Table( + name = "reviews", + indexes = [Index(name = "idx__createdAt", columnList = "created_at")] +) class ReviewEntity( @Id @GeneratedValue(strategy = GenerationType.UUID) From 063b0489f4e9d46dcb40e1c3efd014bdc5256329 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus <153504213+ChungPlusPlus@users.noreply.github.com> Date: Sun, 2 Feb 2025 14:42:18 +0900 Subject: [PATCH 161/162] =?UTF-8?q?Token=20Algorithm=20=EC=A7=80=EC=A0=95?= =?UTF-8?q?=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Cascade 설정 * feat: index 추가중 * feat: 조회 성능 향상을 위한 index 추가 * fix: token algorithm 지정 --- .../interpark/user/UserAccessTokenUtil.kt | 12 +++++++----- .../com/wafflestudio/interpark/user/UserException.kt | 6 ++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/UserAccessTokenUtil.kt b/src/main/kotlin/com/wafflestudio/interpark/user/UserAccessTokenUtil.kt index adc8282..88aaa99 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/UserAccessTokenUtil.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/UserAccessTokenUtil.kt @@ -3,6 +3,7 @@ package com.wafflestudio.interpark.user import com.wafflestudio.interpark.user.persistence.RefreshTokenEntity import com.wafflestudio.interpark.user.persistence.RefreshTokenRepository import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm import io.jsonwebtoken.security.Keys import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component @@ -19,7 +20,7 @@ class UserAccessTokenUtil( val now = Date() val expiryDate = Date(now.time + ACCESS_EXPIRATION_TIME) return Jwts.builder() - .signWith(secretKey) + .signWith(secretKey, SignatureAlgorithm.HS256) .setSubject(username) .setIssuedAt(now) .setExpiration(expiryDate) @@ -33,6 +34,11 @@ class UserAccessTokenUtil( .setSigningKey(secretKey) .build() .parseClaimsJws(accessToken) + .also { jws -> + if (jws.header.algorithm != SignatureAlgorithm.HS256.value) { + throw InvalidTokenException() + } + } .body if (claims.expiration < Date()) { throw TokenExpiredException() @@ -83,9 +89,5 @@ class UserAccessTokenUtil( companion object { private const val ACCESS_EXPIRATION_TIME = 1000 * 60 * 15 // 15 minutes private const val REFRESH_EXPIRATION_TIME = 1000 * 60 * 60 * 24 // 1 day -// @Value("\${jwt.secret}") -// lateinit var secretKey: String -// private val SECRET_KEY = Keys.hmacShaKeyFor(secretKey.toByteArray(StandardCharsets.UTF_8)) - // TODO("비밀키 숨겨야 한다") } } diff --git a/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt b/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt index a5153cc..a77cb5f 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/user/UserException.kt @@ -60,6 +60,12 @@ class TokenExpiredException : UserException( msg = "Token Expired", ) +class InvalidTokenException : UserException( + errorCode = 0, + httpStatusCode = HttpStatus.UNAUTHORIZED, + msg = "Invalid Token(Wrong Signing Algorithm)", +) + class NoRefreshTokenException : UserException( errorCode = 0, httpStatusCode = HttpStatus.UNAUTHORIZED, From c7459b54bbdfcba4705c3e18c8bcbb7187449177 Mon Sep 17 00:00:00 2001 From: ChungPlusPlus Date: Sun, 2 Feb 2025 15:14:34 +0900 Subject: [PATCH 162/162] =?UTF-8?q?fix:=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/wafflestudio/interpark/config/DataInitializer.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt index 1bbf5fa..81fe835 100644 --- a/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt +++ b/src/main/kotlin/com/wafflestudio/interpark/config/DataInitializer.kt @@ -564,12 +564,12 @@ class DataInitializer( Triple( "2025 기리보이 콘서트", "블루스퀘어 마스터카드홀", - generateDateRange("2025-02-10", "2025-02-02", "16:00:00","18:00:00") + generateDateRange("2025-02-05", "2025-02-10", "16:00:00","18:00:00") ), Triple( "2025 검정치마 단독공연", "올림픽공원 올림픽홀", - generateDateRange("2025-02-03", "2025-02-02", "16:00:00","18:00:00") + generateDateRange("2025-02-03", "2025-02-04", "16:00:00","18:00:00") ), Triple( "콜드플레이 내한공연",