Skip to content

Commit

Permalink
feat: 주변 음식점 조회 api (#50)
Browse files Browse the repository at this point in the history
* feat: 주변 음식점 조회 유즈케이스 정의

* feat: 중심 좌표로부터 거리에 따른 검색 영역 생성

* feat: 좌표범위내 음식점 조회 쿼리 추가

* feat: 주변 음식점 조회 구현

* refactor: 좌표내 음식점 조회 메서드 시그니처 수정

* refactor: 음식점 기준으로 주변 음식점 조회 하도록 수정

* feat: 주변 음식점 조회 API 추가
  • Loading branch information
TaeyeonRoyce authored Aug 25, 2024
1 parent e2a4d87 commit b43bc15
Show file tree
Hide file tree
Showing 13 changed files with 266 additions and 39 deletions.
43 changes: 13 additions & 30 deletions src/main/kotlin/com/celuveat/common/utils/geometry/SquarePolygon.kt
Original file line number Diff line number Diff line change
@@ -1,25 +1,11 @@
package com.celuveat.common.utils.geometry

class SquarePolygon private constructor(
private val _lowLongitude: Double?,
private val _highLongitude: Double?,
private val _lowLatitude: Double?,
private val _highLatitude: Double?,
val lowLongitude: Double,
val highLongitude: Double,
val lowLatitude: Double,
val highLatitude: Double,
) {
private val isAvailableBox: Boolean =
listOf(_lowLongitude, _highLongitude, _lowLatitude, _highLatitude).all { it != null }

val lowLongitude: Double
get() = if (isAvailableBox) _lowLongitude!! else throw IllegalStateException()

val highLongitude: Double
get() = if (isAvailableBox) _highLongitude!! else throw IllegalStateException()

val lowLatitude: Double
get() = if (isAvailableBox) _lowLatitude!! else throw IllegalStateException()

val highLatitude: Double
get() = if (isAvailableBox) _highLatitude!! else throw IllegalStateException()

companion object {
fun ofNullable(
Expand All @@ -28,7 +14,7 @@ class SquarePolygon private constructor(
lowLatitude: Double?,
highLatitude: Double?,
): SquarePolygon? = if (listOf(lowLongitude, highLongitude, lowLatitude, highLatitude).all { it != null }) {
SquarePolygon(lowLongitude, highLongitude, lowLatitude, highLatitude)
SquarePolygon(lowLongitude!!, highLongitude!!, lowLatitude!!, highLatitude!!)
} else {
null
}
Expand All @@ -40,22 +26,19 @@ class SquarePolygon private constructor(

other as SquarePolygon

if (_lowLongitude != other._lowLongitude) return false
if (_highLongitude != other._highLongitude) return false
if (_lowLatitude != other._lowLatitude) return false
if (_highLatitude != other._highLatitude) return false
if (isAvailableBox != other.isAvailableBox) return false
if (lowLongitude != other.lowLongitude) return false
if (highLongitude != other.highLongitude) return false
if (lowLatitude != other.lowLatitude) return false
if (highLatitude != other.highLatitude) return false

return true
}

override fun hashCode(): Int {
var result = _lowLongitude?.hashCode() ?: 0
result = 31 * result + (_highLongitude?.hashCode() ?: 0)
result = 31 * result + (_lowLatitude?.hashCode() ?: 0)
result = 31 * result + (_highLatitude?.hashCode() ?: 0)
result = 31 * result + isAvailableBox.hashCode()
var result = lowLongitude.hashCode()
result = 31 * result + highLongitude.hashCode()
result = 31 * result + lowLatitude.hashCode()
result = 31 * result + highLatitude.hashCode()
return result
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,11 @@ interface RestaurantApi {
@Auth auth: AuthContext,
@PageableDefault(size = 10, page = 0) pageable: Pageable,
): SliceResponse<RestaurantPreviewResponse>

@Operation(summary = "주변 음식점 조회")
@GetMapping("/nearby/{restaurantId}")
fun readNearByRestaurants(
@Auth auth: AuthContext,
@PathVariable restaurantId: Long,
): List<RestaurantPreviewResponse>
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import com.celuveat.restaurant.application.port.`in`.command.DeleteInterestedRes
import com.celuveat.restaurant.application.port.`in`.query.ReadCelebrityRecommendRestaurantsQuery
import com.celuveat.restaurant.application.port.`in`.query.ReadCelebrityVisitedRestaurantQuery
import com.celuveat.restaurant.application.port.`in`.query.ReadInterestedRestaurantsQuery
import com.celuveat.restaurant.application.port.`in`.query.ReadNearbyRestaurantsQuery
import com.celuveat.restaurant.application.port.`in`.query.ReadWeeklyUpdateRestaurantsQuery
import com.celuveat.restaurant.application.port.`in`.result.ReadNearbyRestaurantsUseCase
import org.springframework.data.domain.Pageable
import org.springframework.data.web.PageableDefault
import org.springframework.web.bind.annotation.DeleteMapping
Expand All @@ -38,6 +40,7 @@ class RestaurantController(
private val readCelebrityRecommendRestaurantsUseCase: ReadCelebrityRecommendRestaurantsUseCase,
private val readRestaurantsUseCase: ReadRestaurantsUseCase,
private val readWeeklyUpdateRestaurantsUseCase: ReadWeeklyUpdateRestaurantsUseCase,
private val readNearbyRestaurantsUseCase: ReadNearbyRestaurantsUseCase,
) : RestaurantApi {
@GetMapping("/interested")
override fun getInterestedRestaurants(
Expand Down Expand Up @@ -148,4 +151,18 @@ class RestaurantController(
converter = RestaurantPreviewResponse::from,
)
}

@GetMapping("/nearby/{restaurantId}")
override fun readNearByRestaurants(
@Auth auth: AuthContext,
@PathVariable restaurantId: Long,
): List<RestaurantPreviewResponse> {
val result = readNearbyRestaurantsUseCase.readNearbyRestaurants(
ReadNearbyRestaurantsQuery(
memberId = auth.optionalMemberId(),
restaurantId = restaurantId,
),
)
return result.map(RestaurantPreviewResponse::from)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,22 @@ class RestaurantPersistenceAdapter(
)
}

override fun readNearby(id: Long): List<Restaurant> {
val centralRestaurant = restaurantJpaRepository.getById(id)
val restaurants = restaurantJpaRepository.findTop5ByCoordinates(
latitude = centralRestaurant.latitude,
longitude = centralRestaurant.longitude,
)
val imagesByRestaurants = restaurantImageJpaRepository.findByRestaurantIn(restaurants)
.groupBy { it.restaurant.id }
return restaurants.map {
restaurantPersistenceMapper.toDomain(
it,
imagesByRestaurants[it.id]!!,
)
}
}

companion object {
val LATEST_SORTER = Sort.by("createdAt").descending()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ package com.celuveat.restaurant.adapter.out.persistence.entity

import com.celuveat.common.utils.findByIdOrThrow
import com.celuveat.restaurant.exception.NotFoundRestaurantException
import java.time.LocalDateTime
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Slice
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import java.time.LocalDateTime

interface RestaurantJpaRepository : JpaRepository<RestaurantJpaEntity, Long>, CustomRestaurantRepository {
override fun getById(id: Long): RestaurantJpaEntity {
Expand All @@ -19,4 +19,19 @@ interface RestaurantJpaRepository : JpaRepository<RestaurantJpaEntity, Long>, Cu
endOfWeek: LocalDateTime,
pageable: Pageable,
): Slice<RestaurantJpaEntity>

@Query(
"""
SELECT r.*,
(6371 * acos(cos(radians(:latitude)) * cos(radians(r.latitude)) * cos(radians(r.longitude) - radians(:longitude)) + sin(radians(:latitude)) * sin(radians(r.latitude)))) AS distance
FROM restaurant r
ORDER BY distance
LIMIT 5 OFFSET 1 -- 1 is the itself location
""",
nativeQuery = true
)
fun findTop5ByCoordinates(
latitude: Double,
longitude: Double
): List<RestaurantJpaEntity>
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import com.celuveat.restaurant.application.port.`in`.ReadWeeklyUpdateRestaurants
import com.celuveat.restaurant.application.port.`in`.query.ReadCelebrityRecommendRestaurantsQuery
import com.celuveat.restaurant.application.port.`in`.query.ReadCelebrityVisitedRestaurantQuery
import com.celuveat.restaurant.application.port.`in`.query.ReadInterestedRestaurantsQuery
import com.celuveat.restaurant.application.port.`in`.query.ReadNearbyRestaurantsQuery
import com.celuveat.restaurant.application.port.`in`.query.ReadRestaurantsQuery
import com.celuveat.restaurant.application.port.`in`.query.ReadWeeklyUpdateRestaurantsQuery
import com.celuveat.restaurant.application.port.`in`.result.ReadNearbyRestaurantsUseCase
import com.celuveat.restaurant.application.port.`in`.result.RestaurantPreviewResult
import com.celuveat.restaurant.application.port.out.ReadInterestedRestaurantPort
import com.celuveat.restaurant.application.port.out.ReadRestaurantPort
import com.celuveat.restaurant.domain.Restaurant
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.temporal.TemporalAdjusters
Expand All @@ -30,7 +31,8 @@ class RestaurantQueryService(
ReadCelebrityVisitedRestaurantUseCase,
ReadCelebrityRecommendRestaurantsUseCase,
ReadRestaurantsUseCase,
ReadWeeklyUpdateRestaurantsUseCase {
ReadWeeklyUpdateRestaurantsUseCase,
ReadNearbyRestaurantsUseCase {
override fun readInterestedRestaurant(query: ReadInterestedRestaurantsQuery): SliceResult<RestaurantPreviewResult> {
val interestedRestaurants = readInterestedRestaurantPort.readInterestedRestaurants(
query.memberId,
Expand Down Expand Up @@ -60,7 +62,7 @@ class RestaurantQueryService(
return visitedRestaurants.convertContent {
RestaurantPreviewResult.of(
restaurant = it,
liked = interestedRestaurants.contains(it),
liked = interestedRestaurants.contains(it.id),
)
}
}
Expand All @@ -73,7 +75,7 @@ class RestaurantQueryService(
return restaurants.map {
RestaurantPreviewResult.of(
restaurant = it,
liked = interestedRestaurants.contains(it),
liked = interestedRestaurants.contains(it.id),
visitedCelebrities = celebritiesByRestaurants[it.id]!!,
)
}
Expand All @@ -93,7 +95,7 @@ class RestaurantQueryService(
return restaurants.convertContent {
RestaurantPreviewResult.of(
restaurant = it,
liked = interestedRestaurants.contains(it),
liked = interestedRestaurants.contains(it.id),
visitedCelebrities = celebritiesByRestaurants[it.id]!!,
)
}
Expand All @@ -109,7 +111,21 @@ class RestaurantQueryService(
return restaurants.convertContent {
RestaurantPreviewResult.of(
restaurant = it,
liked = interestedRestaurants.contains(it),
liked = interestedRestaurants.contains(it.id),
visitedCelebrities = celebritiesByRestaurants[it.id]!!,
)
}
}

override fun readNearbyRestaurants(query: ReadNearbyRestaurantsQuery): List<RestaurantPreviewResult> {
val restaurants = readRestaurantPort.readNearby(query.restaurantId)
val restaurantIds = restaurants.map { it.id }
val interestedRestaurants = readInterestedRestaurants(query.memberId, restaurantIds)
val celebritiesByRestaurants = readCelebritiesPort.readVisitedCelebritiesByRestaurants(restaurantIds)
return restaurants.map {
RestaurantPreviewResult.of(
restaurant = it,
liked = interestedRestaurants.contains(it.id),
visitedCelebrities = celebritiesByRestaurants[it.id]!!,
)
}
Expand All @@ -118,10 +134,10 @@ class RestaurantQueryService(
private fun readInterestedRestaurants(
memberId: Long?,
restaurantIds: List<Long>,
): Set<Restaurant> {
): Set<Long> {
return memberId?.let {
readInterestedRestaurantPort.readInterestedRestaurantsByIds(it, restaurantIds)
.map { interested -> interested.restaurant }.toSet()
.map { interested -> interested.restaurant.id }.toSet()
} ?: emptySet()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.celuveat.restaurant.application.port.`in`.query

data class ReadNearbyRestaurantsQuery(
val memberId: Long?,
val restaurantId: Long,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.celuveat.restaurant.application.port.`in`.result

import com.celuveat.restaurant.application.port.`in`.query.ReadNearbyRestaurantsQuery

interface ReadNearbyRestaurantsUseCase {
fun readNearbyRestaurants(query: ReadNearbyRestaurantsQuery): List<RestaurantPreviewResult>
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,6 @@ interface ReadRestaurantPort {
page: Int,
size: Int,
): SliceResult<Restaurant>

fun readNearby(id: Long): List<Restaurant>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.celuveat.common.utils.geometry

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe

class SquarePolygonTest : StringSpec({
"SquarePolygon 생성" {
val squarePolygon = SquarePolygon.ofNullable(
lowLongitude = 1.0,
highLongitude = 2.0,
lowLatitude = 3.0,
highLatitude = 4.0
)
squarePolygon shouldNotBe null
}

"좌표가 하나라도 null이면 SquarePolygon은 생성 되지 않는다" {
val squarePolygon = SquarePolygon.ofNullable(
lowLongitude = 1.0,
highLongitude = 2.0,
lowLatitude = 3.0,
highLatitude = null
)
squarePolygon shouldBe null
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import com.celuveat.restaurant.application.port.`in`.command.AddInterestedRestau
import com.celuveat.restaurant.application.port.`in`.command.DeleteInterestedRestaurantCommand
import com.celuveat.restaurant.application.port.`in`.query.ReadCelebrityVisitedRestaurantQuery
import com.celuveat.restaurant.application.port.`in`.query.ReadInterestedRestaurantsQuery
import com.celuveat.restaurant.application.port.`in`.query.ReadNearbyRestaurantsQuery
import com.celuveat.restaurant.application.port.`in`.query.ReadRestaurantsQuery
import com.celuveat.restaurant.application.port.`in`.query.ReadWeeklyUpdateRestaurantsQuery
import com.celuveat.restaurant.application.port.`in`.result.ReadNearbyRestaurantsUseCase
import com.celuveat.restaurant.application.port.`in`.result.RestaurantPreviewResult
import com.celuveat.support.sut
import com.fasterxml.jackson.databind.ObjectMapper
Expand Down Expand Up @@ -48,6 +50,7 @@ class RestaurantControllerTest(
@MockkBean val readCelebrityRecommendRestaurantsUseCase: ReadCelebrityRecommendRestaurantsUseCase,
@MockkBean val readRestaurantsUseCase: ReadRestaurantsUseCase,
@MockkBean val readWeeklyUpdateRestaurantsUseCase: ReadWeeklyUpdateRestaurantsUseCase,
@MockkBean val readNearbyRestaurantsUseCase: ReadNearbyRestaurantsUseCase,
// for AuthMemberArgumentResolver
@MockkBean val extractMemberIdUseCase: ExtractMemberIdUseCase,
) : FunSpec({
Expand Down Expand Up @@ -345,6 +348,30 @@ class RestaurantControllerTest(
}
}
}

context("주변 음식점을 조회 한다") {
val memberId = 1L
val accessToken = "celuveatAccessToken"
test("조회 성공") {
val results = sut.giveMeBuilder<RestaurantPreviewResult>().sampleList(3)
val response = results.map(RestaurantPreviewResponse::from)
val query = ReadNearbyRestaurantsQuery(
memberId = 1L,
restaurantId = 1L,
)
every { extractMemberIdUseCase.extract(accessToken) } returns memberId
every { readNearbyRestaurantsUseCase.readNearbyRestaurants(query) } returns results

mockMvc.get("/restaurants/nearby/{restaurantId}", 1L) {
header("Authorization", "Bearer $accessToken")
}.andExpect {
status { isOk() }
content { json(mapper.writeValueAsString(response)) }
}.andDo {
print()
}
}
}
}) {
override suspend fun afterEach(
testCase: TestCase,
Expand Down
Loading

0 comments on commit b43bc15

Please sign in to comment.