From e01be3cb10956f277fef89297d8086cc79d16156 Mon Sep 17 00:00:00 2001 From: suyeoniii Date: Fri, 13 Sep 2024 15:02:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20#41=20=EB=A3=A8=ED=8A=B8=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 5 ++ .../application/route/SearchRoutesUseCase.kt | 23 +++++++ .../application/route/dto/SearchCommand.kt | 12 ++++ .../controller/route/RouteController.kt | 1 + .../controller/route/RouteSearchController.kt | 61 +++++++++++++++++++ .../controller/route/dto/RouteSortBy.kt | 8 +++ .../controller/route/dto/SearchFilters.kt | 23 +++++++ .../controller/route/dto/SearchRouteDto.kt | 54 ++++++++++++++++ .../route/dto/SearchRoutesResponse.kt | 14 +++++ .../routebox/routebox/domain/route/Route.kt | 5 +- .../routebox/domain/route/RouteService.kt | 56 +++++++++++++++++ .../infrastructure/route/RouteRepository.kt | 3 +- .../routebox/security/SecurityConfig.kt | 2 - src/main/resources/schema.sql | 1 + ...serRoutesResponseLatestRouteUseCaseTest.kt | 1 + ...serRoutesResponseRouteDetailUseCaseTest.kt | 1 + .../route/PurchaseRouteUseCaseTest.kt | 1 + .../routebox/domain/route/RouteServiceTest.kt | 1 + 18 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/com/routebox/routebox/application/route/SearchRoutesUseCase.kt create mode 100644 src/main/kotlin/com/routebox/routebox/application/route/dto/SearchCommand.kt create mode 100644 src/main/kotlin/com/routebox/routebox/controller/route/RouteSearchController.kt create mode 100644 src/main/kotlin/com/routebox/routebox/controller/route/dto/RouteSortBy.kt create mode 100644 src/main/kotlin/com/routebox/routebox/controller/route/dto/SearchFilters.kt create mode 100644 src/main/kotlin/com/routebox/routebox/controller/route/dto/SearchRouteDto.kt create mode 100644 src/main/kotlin/com/routebox/routebox/controller/route/dto/SearchRoutesResponse.kt diff --git a/build.gradle.kts b/build.gradle.kts index 06567e8..73bf13e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -68,6 +68,11 @@ dependencies { implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") + + val jdslVersion = "3.5.2" + implementation("com.linecorp.kotlin-jdsl:jpql-dsl:$jdslVersion") + implementation("com.linecorp.kotlin-jdsl:jpql-render:$jdslVersion") + implementation("com.linecorp.kotlin-jdsl:spring-data-jpa-support:$jdslVersion") } kotlin { diff --git a/src/main/kotlin/com/routebox/routebox/application/route/SearchRoutesUseCase.kt b/src/main/kotlin/com/routebox/routebox/application/route/SearchRoutesUseCase.kt new file mode 100644 index 0000000..b7cf090 --- /dev/null +++ b/src/main/kotlin/com/routebox/routebox/application/route/SearchRoutesUseCase.kt @@ -0,0 +1,23 @@ +package com.routebox.routebox.application.route + +import com.routebox.routebox.application.route.dto.GetRouteDetailResult +import com.routebox.routebox.application.route.dto.SearchCommand +import com.routebox.routebox.domain.route.RouteService +import org.springframework.stereotype.Component + +@Component +class SearchRoutesUseCase( + private val routeService: RouteService, +) { + /** + * 루트 검색. + * + * @param request 검색 조건 + * @return 루트 상세 정보 + * @throws + */ + operator fun invoke(request: SearchCommand): List { + val routes = routeService.searchRoutes(request).map { GetRouteDetailResult.from(it) } + return routes + } +} diff --git a/src/main/kotlin/com/routebox/routebox/application/route/dto/SearchCommand.kt b/src/main/kotlin/com/routebox/routebox/application/route/dto/SearchCommand.kt new file mode 100644 index 0000000..bd5741e --- /dev/null +++ b/src/main/kotlin/com/routebox/routebox/application/route/dto/SearchCommand.kt @@ -0,0 +1,12 @@ +package com.routebox.routebox.application.route.dto + +import com.routebox.routebox.controller.route.dto.RouteSortBy +import com.routebox.routebox.controller.route.dto.SearchFilters + +data class SearchCommand( + val page: Int, + val size: Int, + val filters: SearchFilters, + val query: String?, + val sortBy: RouteSortBy, +) diff --git a/src/main/kotlin/com/routebox/routebox/controller/route/RouteController.kt b/src/main/kotlin/com/routebox/routebox/controller/route/RouteController.kt index 1cee4eb..178e8b9 100644 --- a/src/main/kotlin/com/routebox/routebox/controller/route/RouteController.kt +++ b/src/main/kotlin/com/routebox/routebox/controller/route/RouteController.kt @@ -59,6 +59,7 @@ class RouteController( @Operation( summary = "루트 단건 조회", description = "루트 단건 조회", + security = [SecurityRequirement(name = "access-token")], ) @ApiResponses( ApiResponse(responseCode = "200"), diff --git a/src/main/kotlin/com/routebox/routebox/controller/route/RouteSearchController.kt b/src/main/kotlin/com/routebox/routebox/controller/route/RouteSearchController.kt new file mode 100644 index 0000000..7b5955c --- /dev/null +++ b/src/main/kotlin/com/routebox/routebox/controller/route/RouteSearchController.kt @@ -0,0 +1,61 @@ +package com.routebox.routebox.controller.route + +import com.routebox.routebox.application.route.SearchRoutesUseCase +import com.routebox.routebox.application.route.dto.SearchCommand +import com.routebox.routebox.controller.route.dto.RouteSortBy +import com.routebox.routebox.controller.route.dto.SearchFilters +import com.routebox.routebox.controller.route.dto.SearchRouteDto +import com.routebox.routebox.controller.route.dto.SearchRoutesResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.Parameters +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "루트 검색 관련 API") +@RestController +@Validated +@RequestMapping("/api") +class RouteSearchController( + private val searchRoutesUseCase: SearchRoutesUseCase, +) { + @Operation( + summary = "루트 검색", + description = "검색어, 검색 필터, 정렬 등을 이용한 루트 검색", + security = [SecurityRequirement(name = "access-token")], + ) + @ApiResponses( + ApiResponse(responseCode = "200"), + ) + @Parameters( + Parameter(name = "sortBy", description = "정렬 기준 (NEWEST, OLDEST, POPULAR, COMMENTS)"), + Parameter(name = "whoWith", description = "대상 (예: 친구, 가족)"), + Parameter(name = "numberOfPeople", description = "인원수 (예: 2, 3)"), + Parameter(name = "numberOfDays", description = "머무는 기간 (일 수)"), + Parameter(name = "style", description = "루트 스타일 (예: 모험, 휴식)"), + Parameter(name = "transportation", description = "이동수단 (예: 자동차, 자전거)"), + ) + @GetMapping("/v1/search") + fun searchRoutes( + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "10") size: Int, + @RequestParam(required = false) query: String? = null, + @RequestParam(required = false) sortBy: RouteSortBy = RouteSortBy.NEWEST, + @RequestParam(required = false) whoWith: List = emptyList(), + @RequestParam(required = false) numberOfPeople: List = emptyList(), + @RequestParam(required = false) numberOfDays: List = emptyList(), + @RequestParam(required = false) style: List = emptyList(), + @RequestParam(required = false) transportation: List = emptyList(), + ): SearchRoutesResponse { + val filters = SearchFilters.from(whoWith, numberOfPeople, numberOfDays, style, transportation) + val request = SearchCommand(page, size, filters, query, sortBy) + return SearchRoutesResponse.from(searchRoutesUseCase(request).map { SearchRouteDto.from(it) }) + } +} diff --git a/src/main/kotlin/com/routebox/routebox/controller/route/dto/RouteSortBy.kt b/src/main/kotlin/com/routebox/routebox/controller/route/dto/RouteSortBy.kt new file mode 100644 index 0000000..fb40f68 --- /dev/null +++ b/src/main/kotlin/com/routebox/routebox/controller/route/dto/RouteSortBy.kt @@ -0,0 +1,8 @@ +package com.routebox.routebox.controller.route.dto + +enum class RouteSortBy { + NEWEST, + OLDEST, + POPULAR, + COMMENTS, +} diff --git a/src/main/kotlin/com/routebox/routebox/controller/route/dto/SearchFilters.kt b/src/main/kotlin/com/routebox/routebox/controller/route/dto/SearchFilters.kt new file mode 100644 index 0000000..a1fd306 --- /dev/null +++ b/src/main/kotlin/com/routebox/routebox/controller/route/dto/SearchFilters.kt @@ -0,0 +1,23 @@ +package com.routebox.routebox.controller.route.dto + +data class SearchFilters( + val whoWith: List, + + val numberOfPeople: List, + + val numberOfDays: List, + + val style: List, + + val transportation: List, +) { + companion object { + fun from(whoWith: List, numberOfPeople: List, numberOfDays: List, style: List, transportation: List): SearchFilters = SearchFilters( + whoWith = whoWith, + numberOfPeople = numberOfPeople, + numberOfDays = numberOfDays, + style = style, + transportation = transportation, + ) + } +} diff --git a/src/main/kotlin/com/routebox/routebox/controller/route/dto/SearchRouteDto.kt b/src/main/kotlin/com/routebox/routebox/controller/route/dto/SearchRouteDto.kt new file mode 100644 index 0000000..07321f2 --- /dev/null +++ b/src/main/kotlin/com/routebox/routebox/controller/route/dto/SearchRouteDto.kt @@ -0,0 +1,54 @@ +package com.routebox.routebox.controller.route.dto + +import com.routebox.routebox.application.route.dto.GetRouteDetailResult +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class SearchRouteDto( + @Schema(description = "Id(PK) of route", example = "1") + val routeId: Long, + + @Schema(description = "Id(PK) of user", example = "1") + val userId: Long, + + @Schema(description = "유저 프로필 이미지(url)", example = "https://user-profile-image") + val profileImageUrl: String, + + @Schema(description = "닉네임", example = "고작가") + val nickname: String, + + @Schema(description = "루트 제목", example = "서울의 작가들") + val routeName: String?, + + @Schema(description = "루트 설명", example = "서울의 작가들을 만나보세요.") + val routeDescription: String?, + + @Schema(description = "루트 이미지", example = "https://route-image1") + val routeImageUrl: String, + + @Schema(description = "루트 구매 수", example = "0") + val purchaseCount: Int, + + @Schema(description = "루트 댓글 수", example = "0") + val commentCount: Int, + + @Schema(description = "루트 생성일", example = "2021-08-01T00:00:00") + val createdAt: LocalDateTime?, +) { + companion object { + fun from( + getRouteDetailResult: GetRouteDetailResult, + ): SearchRouteDto = SearchRouteDto( + routeId = getRouteDetailResult.routeId, + userId = getRouteDetailResult.userId, + profileImageUrl = getRouteDetailResult.profileImageUrl, + nickname = getRouteDetailResult.nickname, + routeName = getRouteDetailResult.routeName, + routeDescription = getRouteDetailResult.routeDescription, + routeImageUrl = getRouteDetailResult.routeImageUrls.firstOrNull() ?: "", + purchaseCount = getRouteDetailResult.purchaseCount, + commentCount = getRouteDetailResult.commentCount, + createdAt = getRouteDetailResult.recordFinishedAt, + ) + } +} diff --git a/src/main/kotlin/com/routebox/routebox/controller/route/dto/SearchRoutesResponse.kt b/src/main/kotlin/com/routebox/routebox/controller/route/dto/SearchRoutesResponse.kt new file mode 100644 index 0000000..4a7f57b --- /dev/null +++ b/src/main/kotlin/com/routebox/routebox/controller/route/dto/SearchRoutesResponse.kt @@ -0,0 +1,14 @@ +package com.routebox.routebox.controller.route.dto + +import io.swagger.v3.oas.annotations.media.Schema + +data class SearchRoutesResponse( + @Schema(description = "검색된 루트 리스트") + val routes: List, +) { + companion object { + fun from(routes: List): SearchRoutesResponse = SearchRoutesResponse( + routes = routes, + ) + } +} diff --git a/src/main/kotlin/com/routebox/routebox/domain/route/Route.kt b/src/main/kotlin/com/routebox/routebox/domain/route/Route.kt index 72de77f..bf5b925 100644 --- a/src/main/kotlin/com/routebox/routebox/domain/route/Route.kt +++ b/src/main/kotlin/com/routebox/routebox/domain/route/Route.kt @@ -29,6 +29,7 @@ class Route( numberOfPeople: Int?, numberOfDays: String?, style: Array, + styles: String, transportation: String?, isPublic: Boolean = false, ) : TimeTrackedBaseEntity() { @@ -65,6 +66,8 @@ class Route( @Convert(converter = StringArrayConverter::class) @Column(columnDefinition = "json") var style: Array = style + + var styles: String? = styles private set var transportation: String? = transportation @@ -115,7 +118,7 @@ class Route( } fun updateStyle(style: Array) { - this.style = style + this.styles = style.joinToString(",") } fun updateTransportation(transportation: String) { diff --git a/src/main/kotlin/com/routebox/routebox/domain/route/RouteService.kt b/src/main/kotlin/com/routebox/routebox/domain/route/RouteService.kt index b103157..90fc9e3 100644 --- a/src/main/kotlin/com/routebox/routebox/domain/route/RouteService.kt +++ b/src/main/kotlin/com/routebox/routebox/domain/route/RouteService.kt @@ -1,5 +1,9 @@ package com.routebox.routebox.domain.route +import com.linecorp.kotlinjdsl.querymodel.jpql.sort.Sorts.asc +import com.linecorp.kotlinjdsl.querymodel.jpql.sort.Sorts.desc +import com.routebox.routebox.application.route.dto.SearchCommand +import com.routebox.routebox.controller.route.dto.RouteSortBy import com.routebox.routebox.domain.common.FileManager import com.routebox.routebox.domain.user.User import com.routebox.routebox.exception.route.RouteNotFoundException @@ -77,6 +81,7 @@ class RouteService( whoWith = null, numberOfPeople = null, numberOfDays = null, + styles = "", style = emptyArray(), transportation = null, isPublic = false, @@ -341,4 +346,55 @@ class RouteService( @Transactional(readOnly = true) fun findRouteActivityById(activityId: Long): RouteActivity? = routeActivityRepository.findById(activityId).orElse(null) + + /** + * 루트 검색 + */ + @Transactional(readOnly = true) + fun searchRoutes(request: SearchCommand): List { + val pageable = PageRequest.of(request.page, request.size) + return routeRepository.findPage(pageable) { + select( + entity(Route::class), + ).from( + entity(Route::class), + ).where( + and( + path(Route::isPublic).eq(true), + request.query?.let { + or( + path(Route::name).like("%$it%"), + path(Route::description).like("%$it%"), + ) + }, + request.filters.whoWith.takeIf { it.isNotEmpty() }?.let { + path(Route::whoWith).`in`(it) + }, + request.filters.numberOfPeople.takeIf { it.isNotEmpty() }?.let { + path(Route::numberOfPeople).`in`(it) + }, + request.filters.numberOfDays.takeIf { it.isNotEmpty() }?.let { + path(Route::numberOfDays).`in`(it) + }, + request.filters.style.takeIf { it.isNotEmpty() }?.let { stylesFilter -> + and( + *stylesFilter.map { style -> + path(Route::styles).like("%$style%") + }.toTypedArray(), + ) + }, + request.filters.transportation.takeIf { it.isNotEmpty() }?.let { + path(Route::transportation).`in`(it) + }, + ), + ).orderBy( + when (request.sortBy) { + RouteSortBy.NEWEST -> desc(path(Route::createdAt)) + RouteSortBy.OLDEST -> asc(path(Route::createdAt)) + RouteSortBy.POPULAR -> asc(path(Route::createdAt)) // TODO: implements + RouteSortBy.COMMENTS -> asc(path(Route::createdAt)) // TODO: implements + }, + ) + }.content.filterNotNull() + } } diff --git a/src/main/kotlin/com/routebox/routebox/infrastructure/route/RouteRepository.kt b/src/main/kotlin/com/routebox/routebox/infrastructure/route/RouteRepository.kt index bc3e606..60a19bd 100644 --- a/src/main/kotlin/com/routebox/routebox/infrastructure/route/RouteRepository.kt +++ b/src/main/kotlin/com/routebox/routebox/infrastructure/route/RouteRepository.kt @@ -1,5 +1,6 @@ package com.routebox.routebox.infrastructure.route +import com.linecorp.kotlinjdsl.support.spring.data.jpa.repository.KotlinJdslJpqlExecutor import com.routebox.routebox.domain.route.Route import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable @@ -9,7 +10,7 @@ import org.springframework.stereotype.Repository @Suppress("ktlint:standard:function-naming") @Repository -interface RouteRepository : JpaRepository { +interface RouteRepository : JpaRepository, KotlinJdslJpqlExecutor { @Query( """ SELECT r FROM Route r diff --git a/src/main/kotlin/com/routebox/routebox/security/SecurityConfig.kt b/src/main/kotlin/com/routebox/routebox/security/SecurityConfig.kt index 2031e82..f5abdb5 100644 --- a/src/main/kotlin/com/routebox/routebox/security/SecurityConfig.kt +++ b/src/main/kotlin/com/routebox/routebox/security/SecurityConfig.kt @@ -28,8 +28,6 @@ class SecurityConfig { "/api/v1/auth/login/apple" to HttpMethod.POST, "/api/v1/auth/tokens/refresh" to HttpMethod.POST, "/api/v1/users/nickname/*/availability" to HttpMethod.GET, - "/api/v1/routes" to HttpMethod.GET, - "/api/v1/routes/*" to HttpMethod.GET, "/api/v1/notifications" to HttpMethod.GET, "/api/v1/notifications/unread" to HttpMethod.GET, ) diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 44456e8..e05f67b 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -135,6 +135,7 @@ CREATE TABLE routes number_of_people INT, number_of_days VARCHAR(255), style JSON, + styles VARCHAR(255), transportation VARCHAR(255), is_public BOOLEAN NOT NULL DEFAULT FALSE, record_finished_at DATETIME, diff --git a/src/test/kotlin/com/routebox/routebox/application/route/GetUserRoutesResponseLatestRouteUseCaseTest.kt b/src/test/kotlin/com/routebox/routebox/application/route/GetUserRoutesResponseLatestRouteUseCaseTest.kt index 95ba3bc..3a8be0e 100644 --- a/src/test/kotlin/com/routebox/routebox/application/route/GetUserRoutesResponseLatestRouteUseCaseTest.kt +++ b/src/test/kotlin/com/routebox/routebox/application/route/GetUserRoutesResponseLatestRouteUseCaseTest.kt @@ -103,6 +103,7 @@ class GetUserRoutesResponseLatestRouteUseCaseTest { numberOfPeople = numberOfPeople, numberOfDays = numberOfDays, style = style, + styles = "", transportation = transportation, user = user, ) diff --git a/src/test/kotlin/com/routebox/routebox/application/route/GetUserRoutesResponseRouteDetailUseCaseTest.kt b/src/test/kotlin/com/routebox/routebox/application/route/GetUserRoutesResponseRouteDetailUseCaseTest.kt index 5f76567..6138b97 100644 --- a/src/test/kotlin/com/routebox/routebox/application/route/GetUserRoutesResponseRouteDetailUseCaseTest.kt +++ b/src/test/kotlin/com/routebox/routebox/application/route/GetUserRoutesResponseRouteDetailUseCaseTest.kt @@ -79,6 +79,7 @@ class GetUserRoutesResponseRouteDetailUseCaseTest { numberOfPeople = numberOfPeople, numberOfDays = numberOfDays, style = style, + styles = "", transportation = transportation, user = user, ) diff --git a/src/test/kotlin/com/routebox/routebox/application/route/PurchaseRouteUseCaseTest.kt b/src/test/kotlin/com/routebox/routebox/application/route/PurchaseRouteUseCaseTest.kt index dfb3b28..316468d 100644 --- a/src/test/kotlin/com/routebox/routebox/application/route/PurchaseRouteUseCaseTest.kt +++ b/src/test/kotlin/com/routebox/routebox/application/route/PurchaseRouteUseCaseTest.kt @@ -113,6 +113,7 @@ class PurchaseRouteUseCaseTest { numberOfPeople = 2, numberOfDays = "2박3일", style = arrayOf("힐링"), + styles = "힐링", transportation = "뚜벅뚜벅", ) diff --git a/src/test/kotlin/com/routebox/routebox/domain/route/RouteServiceTest.kt b/src/test/kotlin/com/routebox/routebox/domain/route/RouteServiceTest.kt index 86f5750..6419409 100644 --- a/src/test/kotlin/com/routebox/routebox/domain/route/RouteServiceTest.kt +++ b/src/test/kotlin/com/routebox/routebox/domain/route/RouteServiceTest.kt @@ -127,6 +127,7 @@ class RouteServiceTest { numberOfPeople = 2, numberOfDays = "2박3일", style = arrayOf("힐링"), + styles = "", transportation = "뚜벅뚜벅", ) }