Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/tasks/NOOK-125/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# NOOK-125 DB 전체 장소 및 내 저장 장소 검색 API

## 목적

서비스 DB에 저장된 장소를 이름 또는 주소의 부분 일치로 검색한다. 전체 장소 검색과 로그인 사용자가 저장한 장소 검색을 별도 API로 제공한다.

## 범위

- `GET /api/v1/places/database/search`: DB 전체 장소 검색
- `GET /api/v1/places/my/search`: 현재 사용자가 저장한 장소 검색
- 장소명 또는 주소에 `LIKE %query%` 조건 적용
- 장소명과 장소 ID 오름차순 정렬
- page/size 기반 slice 응답
- 장소 기본 정보, 썸네일, 대표 태그와 사용자 저장 여부 응답
- Controller HTTP 예제와 계층별 테스트 추가

## 제외 범위

- 기존 외부 provider 장소 검색 API 변경
- 전문 검색 엔진 또는 n-gram 검색
- 데이터베이스 스키마 변경

## 검증

- `./gradlew detekt`
- `./gradlew test`
- `./gradlew check`
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.every.nook.api.application.place

import org.every.nook.api.application.place.port.SearchAllStoredPlacesPort

class SearchAllStoredPlacesUseCase(private val port: SearchAllStoredPlacesPort) {
operator fun invoke(query: Query): StoredPlaceSearchSliceView {
val keyword = query.keyword.trim()
require(keyword.isNotEmpty()) { "Place search keyword must not be blank" }
require(query.page >= 0) { "Place search page is invalid" }
require(query.size in 1..MAX_PAGE_SIZE) { "Place search page size is invalid" }
val rows = port.searchAll(query.userId, keyword, Math.multiplyExact(query.page, query.size), query.size + 1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject page values that overflow the offset

When a client supplies a nonnegative page whose page * size exceeds Int.MAX_VALUE—for example page=21474837&size=100, which passes the controller constraints—Math.multiplyExact throws ArithmeticException. GlobalExceptionHandler does not classify that as an invalid request, so both new search endpoints return HTTP 500 for validly parsed client input; bound the page/offset or calculate it in a wider type and reject out-of-range values as HTTP 400.

Useful? React with 👍 / 👎.

return rows.toSlice(query.page, query.size)
}

data class Query(val userId: Long, val keyword: String, val page: Int, val size: Int)
}

internal const val MAX_STORED_PLACE_SEARCH_PAGE_SIZE = 100

internal fun List<StoredPlaceSearchView>.toSlice(page: Int, size: Int): StoredPlaceSearchSliceView =
StoredPlaceSearchSliceView(
items = take(size),
page = page,
size = size,
hasNext = this.size > size,
)

private const val MAX_PAGE_SIZE = MAX_STORED_PLACE_SEARCH_PAGE_SIZE
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.every.nook.api.application.place

import org.every.nook.api.application.place.port.SearchMyStoredPlacesPort

class SearchMyStoredPlacesUseCase(private val port: SearchMyStoredPlacesPort) {
operator fun invoke(query: Query): StoredPlaceSearchSliceView {
val keyword = query.keyword.trim()
require(keyword.isNotEmpty()) { "Place search keyword must not be blank" }
require(query.page >= 0) { "Place search page is invalid" }
require(query.size in 1..MAX_STORED_PLACE_SEARCH_PAGE_SIZE) { "Place search page size is invalid" }
val rows = port.searchMine(query.userId, keyword, Math.multiplyExact(query.page, query.size), query.size + 1)
return rows.toSlice(query.page, query.size)
}

data class Query(val userId: Long, val keyword: String, val page: Int, val size: Int)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.every.nook.api.application.place

import java.math.BigDecimal

data class StoredPlaceSearchView(
val id: Long,
val name: String,
val address: String,
val category: String?,
val latitude: BigDecimal,
val longitude: BigDecimal,
val thumbnailUrl: String?,
val tags: List<String>,
val bookmarked: Boolean,
)

data class StoredPlaceSearchSliceView(
val items: List<StoredPlaceSearchView>,
val page: Int,
val size: Int,
val hasNext: Boolean,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.every.nook.api.application.place.port

import org.every.nook.api.application.place.StoredPlaceSearchView

fun interface SearchAllStoredPlacesPort {
fun searchAll(userId: Long, keyword: String, offset: Int, limit: Int): List<StoredPlaceSearchView>
}

fun interface SearchMyStoredPlacesPort {
fun searchMine(userId: Long, keyword: String, offset: Int, limit: Int): List<StoredPlaceSearchView>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package org.every.nook.api.application.place

import org.every.nook.api.application.place.port.SearchAllStoredPlacesPort
import org.every.nook.api.application.place.port.SearchMyStoredPlacesPort
import java.math.BigDecimal
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue

class SearchStoredPlacesUseCaseTest {
@Test
fun `searches all places with trimmed keyword and one extra row`() {
var captured: List<Any> = emptyList()
val useCase = SearchAllStoredPlacesUseCase(
SearchAllStoredPlacesPort { userId, keyword, offset, limit ->
captured = listOf(userId, keyword, offset, limit)
listOf(place(1), place(2), place(3))
},
)

val result = useCase(SearchAllStoredPlacesUseCase.Query(7, " 용산 ", 1, 2))

assertEquals(listOf(7L, "용산", 2, 3), captured)
assertEquals(listOf(1L, 2L), result.items.map { it.id })
assertTrue(result.hasNext)
}

@Test
fun `searches only my places`() {
val useCase = SearchMyStoredPlacesUseCase(
SearchMyStoredPlacesPort { userId, keyword, offset, limit ->
assertEquals(listOf(7L, "카페", 0, 21), listOf(userId, keyword, offset, limit))
listOf(place(1, bookmarked = true))
},
)

val result = useCase(SearchMyStoredPlacesUseCase.Query(7, "카페", 0, 20))

assertEquals(1, result.items.size)
assertTrue(result.items.single().bookmarked)
}

@Test
fun `rejects blank keyword`() {
val useCase = SearchAllStoredPlacesUseCase(SearchAllStoredPlacesPort { _, _, _, _ -> emptyList() })

assertFailsWith<IllegalArgumentException> {
useCase(SearchAllStoredPlacesUseCase.Query(7, " ", 0, 20))
}
}

private fun place(id: Long, bookmarked: Boolean = false): StoredPlaceSearchView = StoredPlaceSearchView(
id = id,
name = "장소$id",
address = "서울",
category = "카페",
latitude = BigDecimal("37.5"),
longitude = BigDecimal("127.0"),
thumbnailUrl = null,
tags = emptyList(),
bookmarked = bookmarked,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ package org.every.nook.api.infrastructure.config

import org.every.nook.api.application.place.GetMapPlacesUseCase
import org.every.nook.api.application.place.GetRecentPlacesUseCase
import org.every.nook.api.application.place.SearchAllStoredPlacesUseCase
import org.every.nook.api.application.place.SearchMyStoredPlacesUseCase
import org.every.nook.api.application.place.port.PlaceMapQueryPort
import org.every.nook.api.application.place.port.SearchAllStoredPlacesPort
import org.every.nook.api.application.place.port.SearchMyStoredPlacesPort
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

Expand All @@ -15,4 +19,12 @@ class PlaceMapQueryUseCaseConfig {
@Bean
fun getRecentPlacesUseCase(placeMapQueryPort: PlaceMapQueryPort): GetRecentPlacesUseCase =
GetRecentPlacesUseCase(placeMapQueryPort)

@Bean
fun searchAllStoredPlacesUseCase(port: SearchAllStoredPlacesPort): SearchAllStoredPlacesUseCase =
SearchAllStoredPlacesUseCase(port)

@Bean
fun searchMyStoredPlacesUseCase(port: SearchMyStoredPlacesPort): SearchMyStoredPlacesUseCase =
SearchMyStoredPlacesUseCase(port)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package org.every.nook.api.infrastructure.persistence.place

import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.Repository
import org.springframework.data.repository.query.Param
import java.math.BigDecimal

interface StoredPlaceSearchJpaRepository : Repository<PlaceEntity, Long> {
@Query(
value = """
SELECT
p.id AS id,
p.name AS name,
p.address AS address,
p.category AS category,
p.latitude AS latitude,
p.longitude AS longitude,
p.thumbnail_url AS thumbnailUrl,
CAST(p.representative_tags AS CHAR) AS representativeTags,
EXISTS (
SELECT 1
FROM user_place_bookmarks upb
WHERE upb.user_id = :userId
AND upb.place_id = p.id
AND EXISTS (
SELECT 1
FROM user_saved_posts usp
INNER JOIN post_places pp ON pp.post_id = usp.post_id
WHERE usp.user_id = upb.user_id
AND usp.deleted_at IS NULL
AND pp.place_id = p.id
)
) AS bookmarked
FROM places p
WHERE (p.name LIKE CONCAT('%', :keyword, '%') OR p.address LIKE CONCAT('%', :keyword, '%'))
ORDER BY p.name ASC, p.id ASC
LIMIT :limit OFFSET :offset
""",
nativeQuery = true,
)
fun searchAll(
@Param("userId") userId: Long,
@Param("keyword") keyword: String,
@Param("offset") offset: Int,
@Param("limit") limit: Int,
): List<StoredPlaceSearchProjection>

@Query(
value = """
SELECT
p.id AS id,
p.name AS name,
p.address AS address,
p.category AS category,
p.latitude AS latitude,
p.longitude AS longitude,
p.thumbnail_url AS thumbnailUrl,
CAST(p.representative_tags AS CHAR) AS representativeTags,
TRUE AS bookmarked
FROM places p
WHERE (p.name LIKE CONCAT('%', :keyword, '%') OR p.address LIKE CONCAT('%', :keyword, '%'))
AND EXISTS (
SELECT 1
FROM user_place_bookmarks upb
WHERE upb.user_id = :userId
AND upb.place_id = p.id
AND EXISTS (
SELECT 1
FROM user_saved_posts usp
INNER JOIN post_places pp ON pp.post_id = usp.post_id
WHERE usp.user_id = upb.user_id
AND usp.deleted_at IS NULL
AND pp.place_id = p.id
)
)
ORDER BY p.name ASC, p.id ASC
LIMIT :limit OFFSET :offset
""",
nativeQuery = true,
)
fun searchMine(
@Param("userId") userId: Long,
@Param("keyword") keyword: String,
@Param("offset") offset: Int,
@Param("limit") limit: Int,
): List<StoredPlaceSearchProjection>
}

interface StoredPlaceSearchProjection {
val id: Long
val name: String
val address: String
val category: String?
val latitude: BigDecimal
val longitude: BigDecimal
val thumbnailUrl: String?
val representativeTags: String?
val bookmarked: Boolean

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Convert native bookmark values before projecting as Boolean

For any non-empty result, both native queries expose EXISTS (...) or TRUE as a MySQL numeric value, but this projection requests Boolean; Connector/J returns these expressions as Long, causing a ClassCastException when toView() reads bookmarked and turning successful searches into HTTP 500 responses. This repository already documents the same failure mode in docs/tasks/NOOK-93/README.md; project the numeric value and explicitly convert it to Boolean instead.

Useful? React with 👍 / 👎.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package org.every.nook.api.infrastructure.persistence.place

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.every.nook.api.application.place.StoredPlaceSearchView
import org.every.nook.api.application.place.port.SearchAllStoredPlacesPort
import org.every.nook.api.application.place.port.SearchMyStoredPlacesPort
import org.every.nook.api.domain.place.PlaceTag
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional

@Component
class StoredPlaceSearchPersistenceAdapter(
private val repository: StoredPlaceSearchJpaRepository,
private val objectMapper: ObjectMapper = jacksonObjectMapper(),
) : SearchAllStoredPlacesPort,
SearchMyStoredPlacesPort {
@Transactional(readOnly = true)
override fun searchAll(userId: Long, keyword: String, offset: Int, limit: Int): List<StoredPlaceSearchView> =
repository.searchAll(userId, keyword, offset, limit).map { it.toView() }

@Transactional(readOnly = true)
override fun searchMine(userId: Long, keyword: String, offset: Int, limit: Int): List<StoredPlaceSearchView> =
repository.searchMine(userId, keyword, offset, limit).map { it.toView() }

private fun StoredPlaceSearchProjection.toView(): StoredPlaceSearchView = StoredPlaceSearchView(
id = id,
name = name,
address = address,
category = category,
latitude = latitude,
longitude = longitude,
thumbnailUrl = thumbnailUrl,
tags = representativeTags.toDisplayTags(),
bookmarked = bookmarked,
)

private fun String?.toDisplayTags(): List<String> = if (isNullOrBlank()) {
emptyList()
} else {
objectMapper.readValue(this, Array<String>::class.java).map { PlaceTag.valueOf(it).displayName }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package org.every.nook.api.infrastructure.persistence.place

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import java.math.BigDecimal
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class StoredPlaceSearchPersistenceAdapterTest {
private val repository = mock(StoredPlaceSearchJpaRepository::class.java)
private val adapter = StoredPlaceSearchPersistenceAdapter(repository, jacksonObjectMapper())

@Test
fun `maps all place search result and representative tags`() {
val projection = projection(bookmarked = false)
`when`(repository.searchAll(7, "용산", 20, 21)).thenReturn(listOf(projection))

val result = adapter.searchAll(7, "용산", 20, 21)

assertEquals("원동미나리삼겹살", result.single().name)
assertEquals(listOf("조용한", "혼밥"), result.single().tags)
assertEquals(false, result.single().bookmarked)
verify(repository).searchAll(7, "용산", 20, 21)
}

@Test
fun `returns only repository results for my place search`() {
val projection = projection(bookmarked = true)
`when`(repository.searchMine(7, "용산", 0, 21)).thenReturn(listOf(projection))

val result = adapter.searchMine(7, "용산", 0, 21)

assertTrue(result.single().bookmarked)
verify(repository).searchMine(7, "용산", 0, 21)
}

private fun projection(bookmarked: Boolean): StoredPlaceSearchProjection = object : StoredPlaceSearchProjection {
override val id = 17L
override val name = "원동미나리삼겹살"
override val address = "서울 용산구"
override val category = "한식"
override val latitude = BigDecimal("37.5")
override val longitude = BigDecimal("127.0")
override val thumbnailUrl: String? = null
override val representativeTags = "[\"QUIET\",\"SOLO_DINING\"]"
override val bookmarked = bookmarked
}
}
Loading