diff --git a/src/main/kotlin/com/retrip/map/application/in/request/LocationDetailRecentSearchModel.kt b/src/main/kotlin/com/retrip/map/application/in/request/LocationDetailRecentSearchModel.kt new file mode 100644 index 0000000..7b681d1 --- /dev/null +++ b/src/main/kotlin/com/retrip/map/application/in/request/LocationDetailRecentSearchModel.kt @@ -0,0 +1,10 @@ +package com.retrip.map.application.`in`.request + +import java.time.LocalDateTime +import java.util.UUID + +data class LocationDetailRecentSearchModel( + val memberId: UUID, + val searchText: String, + val updateTime: LocalDateTime = LocalDateTime.now(), +) diff --git a/src/main/kotlin/com/retrip/map/application/in/response/LocationDetailRecentSearchResponse.kt b/src/main/kotlin/com/retrip/map/application/in/response/LocationDetailRecentSearchResponse.kt new file mode 100644 index 0000000..b331e29 --- /dev/null +++ b/src/main/kotlin/com/retrip/map/application/in/response/LocationDetailRecentSearchResponse.kt @@ -0,0 +1,9 @@ +package com.retrip.map.application.`in`.response + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "상세 장소 최근 조회 Response") +data class LocationDetailRecentSearchResponse( + @Schema(description = "검색어") + val searchTexts: List? = null, +) diff --git a/src/main/kotlin/com/retrip/map/application/in/service/LocationDetailRecentSearchService.kt b/src/main/kotlin/com/retrip/map/application/in/service/LocationDetailRecentSearchService.kt new file mode 100644 index 0000000..6fc847e --- /dev/null +++ b/src/main/kotlin/com/retrip/map/application/in/service/LocationDetailRecentSearchService.kt @@ -0,0 +1,75 @@ +package com.retrip.map.application.`in`.service + +import com.retrip.map.application.`in`.request.LocationDetailRecentSearchModel +import com.retrip.map.application.`in`.request.context.UserContext +import com.retrip.map.application.`in`.response.LocationDetailRecentSearchResponse +import com.retrip.map.application.`in`.usecase.LocationDetailRecentSearchUseCase +import com.retrip.map.application.out.repository.LocationDetailRecentSearchRepository +import com.retrip.map.domain.entity.LocationDetailRecentSearch +import lombok.RequiredArgsConstructor +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional + +@Service +@RequiredArgsConstructor +class LocationDetailRecentSearchService( + private val locationDetailRecentSearchRepository: LocationDetailRecentSearchRepository +) : LocationDetailRecentSearchUseCase { + + @Transactional(readOnly = true) + override fun getRecentLocationDetail(context: UserContext): LocationDetailRecentSearchResponse? { + val pageRequest = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "lastSearchedAt")) + val result = locationDetailRecentSearchRepository.findByMemberId(context.memberId, pageRequest) + return result?.let { + LocationDetailRecentSearchResponse( + it.map { recentSearch -> recentSearch.keyword } + ) + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + override fun addLocationDetailRecentSearch(model: LocationDetailRecentSearchModel) { + val existing = locationDetailRecentSearchRepository.findByMemberIdAndKeyword( + model.memberId, + model.searchText, + ) + if (existing != null) { + existing.updateTime(model.updateTime) + } else { + locationDetailRecentSearchRepository.save( + LocationDetailRecentSearch.create( + model.memberId, + model.searchText, + model.updateTime + ) + ) + } + val pageRequest = PageRequest.of(9, 1, Sort.by(Sort.Direction.DESC, "lastSearchedAt")) + val threshold = locationDetailRecentSearchRepository.findThresholdTime( + model.memberId, + pageRequest + )?.firstOrNull() + + threshold?.let { + locationDetailRecentSearchRepository.deleteOlderThan(model.memberId, it) + } + } + + @Transactional + override fun deleteRecentLocationDetailsByKeyword(context: UserContext, keyword: String?) { + if (keyword != null) { + val recentSearch = locationDetailRecentSearchRepository.findByMemberIdAndKeyword(context.memberId, keyword) + recentSearch?.run { + locationDetailRecentSearchRepository.delete(this) + } + } else { + val recentSearches = locationDetailRecentSearchRepository.findByMemberId(context.memberId) + recentSearches?.run { + locationDetailRecentSearchRepository.deleteAllInBatch(this) + } + } + } +} diff --git a/src/main/kotlin/com/retrip/map/application/in/service/LocationDetailSearchService.kt b/src/main/kotlin/com/retrip/map/application/in/service/LocationDetailSearchService.kt index 8b41ca8..8ceca2b 100644 --- a/src/main/kotlin/com/retrip/map/application/in/service/LocationDetailSearchService.kt +++ b/src/main/kotlin/com/retrip/map/application/in/service/LocationDetailSearchService.kt @@ -1,15 +1,17 @@ package com.retrip.map.application.`in`.service +import com.retrip.map.application.`in`.request.LocationDetailRecentSearchModel +import com.retrip.map.application.`in`.request.context.UserContext import com.retrip.map.application.`in`.response.LocationDetailSearchResponse +import com.retrip.map.application.`in`.usecase.LocationDetailRecentSearchUseCase import com.retrip.map.application.`in`.usecase.LocationDetailSearchUseCase -import com.retrip.map.application.out.repository.LocationDetailElasticRepository import com.retrip.map.application.out.repository.LocationDetailSearchQueryRepository -import com.retrip.map.application.out.repository.LocationSearchQueryRepository import lombok.RequiredArgsConstructor import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime import java.util.UUID @Service @@ -17,10 +19,20 @@ import java.util.UUID @Transactional(readOnly = true) class LocationDetailSearchService( private val locationDetailSearchQueryRepository: LocationDetailSearchQueryRepository, + private val locationDetailRecentSearchUseCase: LocationDetailRecentSearchUseCase, ) : LocationDetailSearchUseCase { - override fun getDetailLocation(locationId: UUID?, searchText: String?, page: Pageable): Page { + override fun getDetailLocation(locationId: UUID?, searchText: String?, page: Pageable, context: UserContext): Page { val locationDetails = locationDetailSearchQueryRepository.findByLocationIdAndSearchText(locationId, searchText, page) + if (!searchText.isNullOrBlank()) { + locationDetailRecentSearchUseCase.addLocationDetailRecentSearch( + LocationDetailRecentSearchModel( + searchText = searchText, + memberId = context.memberId, + updateTime = LocalDateTime.now(), + ) + ) + } return locationDetails.map { LocationDetailSearchResponse( id = it.id, @@ -37,4 +49,3 @@ class LocationDetailSearchService( } } } - diff --git a/src/main/kotlin/com/retrip/map/application/in/usecase/LocationDetailRecentSearchUseCase.kt b/src/main/kotlin/com/retrip/map/application/in/usecase/LocationDetailRecentSearchUseCase.kt new file mode 100644 index 0000000..bb5f83b --- /dev/null +++ b/src/main/kotlin/com/retrip/map/application/in/usecase/LocationDetailRecentSearchUseCase.kt @@ -0,0 +1,11 @@ +package com.retrip.map.application.`in`.usecase + +import com.retrip.map.application.`in`.request.LocationDetailRecentSearchModel +import com.retrip.map.application.`in`.request.context.UserContext +import com.retrip.map.application.`in`.response.LocationDetailRecentSearchResponse + +interface LocationDetailRecentSearchUseCase { + fun getRecentLocationDetail(context: UserContext): LocationDetailRecentSearchResponse? + fun addLocationDetailRecentSearch(model: LocationDetailRecentSearchModel) + fun deleteRecentLocationDetailsByKeyword(context: UserContext, keyword: String?) +} diff --git a/src/main/kotlin/com/retrip/map/application/in/usecase/LocationDetailSearchUseCase.kt b/src/main/kotlin/com/retrip/map/application/in/usecase/LocationDetailSearchUseCase.kt index 2c5d57b..128309a 100644 --- a/src/main/kotlin/com/retrip/map/application/in/usecase/LocationDetailSearchUseCase.kt +++ b/src/main/kotlin/com/retrip/map/application/in/usecase/LocationDetailSearchUseCase.kt @@ -1,10 +1,11 @@ package com.retrip.map.application.`in`.usecase +import com.retrip.map.application.`in`.request.context.UserContext import com.retrip.map.application.`in`.response.LocationDetailSearchResponse import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import java.util.UUID interface LocationDetailSearchUseCase { - fun getDetailLocation(locationId: UUID?,searchText: String?, page: Pageable): Page + fun getDetailLocation(locationId: UUID?, searchText: String?, page: Pageable, context: UserContext): Page } diff --git a/src/main/kotlin/com/retrip/map/application/out/repository/LocationDetailRecentSearchRepository.kt b/src/main/kotlin/com/retrip/map/application/out/repository/LocationDetailRecentSearchRepository.kt new file mode 100644 index 0000000..c8d7772 --- /dev/null +++ b/src/main/kotlin/com/retrip/map/application/out/repository/LocationDetailRecentSearchRepository.kt @@ -0,0 +1,34 @@ +package com.retrip.map.application.out.repository + +import com.retrip.map.domain.entity.LocationDetailRecentSearch +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import java.time.LocalDateTime +import java.util.* + +interface LocationDetailRecentSearchRepository : JpaRepository { + + fun findByMemberIdAndKeyword(memberId: UUID, keyword: String): LocationDetailRecentSearch? + fun findByMemberId(memberId: UUID, pageable: Pageable): List? + fun findByMemberId(memberId: UUID): List? + + @Query( + """ + SELECT r.lastSearchedAt + FROM LocationDetailRecentSearch r + WHERE r.memberId = :memberId + ORDER BY r.lastSearchedAt DESC + """ + ) + fun findThresholdTime(memberId: UUID, pageable: Pageable): List? + + @Modifying + @Query( + """ + DELETE FROM LocationDetailRecentSearch r WHERE r.memberId = :memberId AND r.lastSearchedAt < :threshold + """ + ) + fun deleteOlderThan(memberId: UUID, threshold: LocalDateTime) +} diff --git a/src/main/kotlin/com/retrip/map/domain/entity/LocationDetailRecentSearch.kt b/src/main/kotlin/com/retrip/map/domain/entity/LocationDetailRecentSearch.kt new file mode 100644 index 0000000..8bdfb5b --- /dev/null +++ b/src/main/kotlin/com/retrip/map/domain/entity/LocationDetailRecentSearch.kt @@ -0,0 +1,60 @@ +package com.retrip.map.domain.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Index +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import jakarta.persistence.Version +import lombok.AccessLevel +import lombok.NoArgsConstructor +import lombok.Setter +import java.time.LocalDateTime +import java.util.* + +@Entity +@Table( + name = "location_detail_recent_search", + uniqueConstraints = [UniqueConstraint( + name = "uk_location_detail_user_keyword", + columnNames = ["keyword", "memberId"] + )], + indexes = [ + Index(name = "idx_user_detail_recent_searched", columnList = "member_id, last_searched_at DESC") + ] +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Setter(value = AccessLevel.PROTECTED) +class LocationDetailRecentSearch( + @Id + @Column(columnDefinition = "varbinary(16)") + val id: UUID? = null, + + @Column(nullable = false) + val keyword: String, + + @Column(nullable = false) + val memberId: UUID, + + @Column(nullable = false) + var lastSearchedAt: LocalDateTime, + + @Version + private val version: Long? = null, +) : BaseEntity() { + fun updateTime(updateTime: LocalDateTime) { + this.lastSearchedAt = updateTime + } + + companion object { + fun create(memberId: UUID, searchText: String, updateTime: LocalDateTime): LocationDetailRecentSearch { + return LocationDetailRecentSearch( + id = UUID.randomUUID(), + keyword = searchText, + memberId = memberId, + lastSearchedAt = updateTime + ) + } + } +} diff --git a/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/rest/LocationDetailSearchController.kt b/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/rest/LocationDetailSearchController.kt index 09ad0f1..d002ee9 100644 --- a/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/rest/LocationDetailSearchController.kt +++ b/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/rest/LocationDetailSearchController.kt @@ -1,16 +1,20 @@ package com.retrip.map.infra.adapter.`in`.presentation.rest +import com.retrip.map.application.`in`.request.context.UserContext +import com.retrip.map.application.`in`.request.context.WithUserContext +import com.retrip.map.application.`in`.response.LocationDetailRecentSearchResponse import com.retrip.map.application.`in`.response.LocationDetailSearchResponse +import com.retrip.map.application.`in`.usecase.LocationDetailRecentSearchUseCase import com.retrip.map.application.`in`.usecase.LocationDetailSearchUseCase import com.retrip.map.infra.adapter.`in`.presentation.common.ApiResponse import com.retrip.map.infra.adapter.`in`.presentation.common.PageUtils import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.tags.Tag import lombok.RequiredArgsConstructor import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.web.PageableDefault +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam @@ -22,18 +26,39 @@ import java.util.* @RequestMapping("search/location-details") @Tag(name = "LocationDetail", description = "여행 상세 지역 정보 조회용 API 입니다.") class LocationDetailSearchController( - private val locationDetailSearchUseCase: LocationDetailSearchUseCase + private val locationDetailSearchUseCase: LocationDetailSearchUseCase, + private val locationDetailRecentSearchUseCase: LocationDetailRecentSearchUseCase, ) { @Operation(summary = "여행 상세 지역 조회", description = "여행 상세 지역 조회(검색엔진)시, 사용하는 API 입니다.") @GetMapping("") fun getLocation( + @WithUserContext context: UserContext, @PageableDefault(size = 10, page = 0) page: Pageable, @RequestParam(name = "locationId", required = false) locationId: UUID?, @RequestParam(name = "searchText", required = false) searchText: String? ): ApiResponse> { val safePageable = PageUtils.getSafePageable(page) - val result = locationDetailSearchUseCase.getDetailLocation(locationId, searchText, safePageable) + val result = locationDetailSearchUseCase.getDetailLocation(locationId, searchText, safePageable, context) return ApiResponse.ok(result) } + + @GetMapping("recent") + @Operation(summary = "최근 여행 상세 지역 조회", description = "최근 여행 상세 지역 조회 API 입니다.") + fun getRecentLocationDetail( + @WithUserContext context: UserContext + ): ApiResponse { + val result = locationDetailRecentSearchUseCase.getRecentLocationDetail(context) + return ApiResponse.ok(result) + } + + @DeleteMapping("recent") + @Operation(summary = "최근 여행 상세 지역 조회 제거", description = "최근 여행 상세 지역 조회 제거 API 입니다.") + fun deleteRecentLocationDetailsByKeyword( + @WithUserContext context: UserContext, + @RequestParam("keyword", required = false) keyword: String? + ): ApiResponse { + locationDetailRecentSearchUseCase.deleteRecentLocationDetailsByKeyword(context, keyword) + return ApiResponse.noContent() + } } diff --git a/src/main/kotlin/com/retrip/map/infra/adapter/out/persistence/elasticsearch/convert/LocationDetailsQueryDocumentConvert.kt b/src/main/kotlin/com/retrip/map/infra/adapter/out/persistence/elasticsearch/convert/LocationDetailsQueryDocumentConvert.kt index 90cf2bb..95f2dc8 100644 --- a/src/main/kotlin/com/retrip/map/infra/adapter/out/persistence/elasticsearch/convert/LocationDetailsQueryDocumentConvert.kt +++ b/src/main/kotlin/com/retrip/map/infra/adapter/out/persistence/elasticsearch/convert/LocationDetailsQueryDocumentConvert.kt @@ -13,17 +13,16 @@ object LocationDetailsQueryDocumentConvert { id = this.id?.firstOrNull()?.let { UUID.fromString(it) } ?: throw IllegalArgumentException("Cannot find location query document id"), name = this.name?.firstOrNull() ?: throw IllegalArgumentException("Cannot find location query document name"), category = this.category?.firstOrNull() ?: throw IllegalArgumentException("Cannot find location query document category"), - searchText = this.searchText?.firstOrNull() ?: throw IllegalArgumentException("Cannot find location query document searchText"), - latitude = this.latitude?.firstOrNull() ?: throw IllegalArgumentException("Cannot find location query document latitude"), - longitude = this.longitude?.firstOrNull() ?: throw IllegalArgumentException("Cannot find location query document longitude"), - createdAt = this.createdAt?.firstOrNull() ?: throw IllegalArgumentException("Cannot find location query document createdAt"), - editedAt = this.editedAt?.firstOrNull() ?: throw IllegalArgumentException("Cannot find location query document editedAt"), - roadAddress = this.roadAddress?.firstOrNull() ?: throw IllegalArgumentException("Cannot find location query document roadAddress"), - telephone = this.telephone?.firstOrNull() ?: throw IllegalArgumentException("Cannot find location query document telephone"), - address = this.address?.firstOrNull() ?: throw IllegalArgumentException("Cannot find location query document address"), - description = this.description?.firstOrNull() ?: throw IllegalArgumentException("Cannot find location query document description"), - locationId = this.locationId?.firstOrNull()?.let { UUID.fromString(it) } ?: throw IllegalArgumentException("Cannot find location query document locationId"), + searchText = this.searchText?.firstOrNull(), + latitude = this.latitude?.firstOrNull(), + longitude = this.longitude?.firstOrNull(), + createdAt = this.createdAt?.firstOrNull(), + editedAt = this.editedAt?.firstOrNull(), + roadAddress = this.roadAddress?.firstOrNull(), + telephone = this.telephone?.firstOrNull(), + address = this.address?.firstOrNull(), + description = this.description?.firstOrNull(), + locationId = this.locationId?.firstOrNull()?.let { UUID.fromString(it) } ?: throw IllegalArgumentException("Cannot find location query document locationId"), ) - } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index cf778ab..2b4bb9b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -41,3 +41,9 @@ jwt: u59J3+ToLRrIqIszRZqmasrWTL2/ihPO76PSTIMAsJMScjAwjUXA47YOOy6Vkzy8r3bPTYHp5C1N KucWwwIDAQAB -----END PUBLIC KEY----- +#logging: +# level: + #tracer: TRACE + #org.springframework.data.elasticsearch.client.WIRE: TRACE + #org.elasticsearch.client.RestClient: TRACE + #org.springframework.data.elasticsearch.core: DEBUG \ No newline at end of file diff --git a/src/main/resources/elasticsearch/mappings/mapping-location-detail.json b/src/main/resources/elasticsearch/mappings/mapping-location-detail.json index 868e433..3b5980a 100644 --- a/src/main/resources/elasticsearch/mappings/mapping-location-detail.json +++ b/src/main/resources/elasticsearch/mappings/mapping-location-detail.json @@ -10,7 +10,8 @@ }, "searchText": { "type": "text", - "analyzer": "korean" + "analyzer": "korean_ngram", + "search_analyzer": "korean_ngram" }, "category": { "type": "keyword" @@ -27,6 +28,9 @@ "roadAddress": { "type": "keyword" }, + "locationId": { + "type": "keyword" + }, "latitude": { "type": "double" }, diff --git a/src/main/resources/elasticsearch/mappings/mapping-location.json b/src/main/resources/elasticsearch/mappings/mapping-location.json index 8c203ee..ce6f939 100644 --- a/src/main/resources/elasticsearch/mappings/mapping-location.json +++ b/src/main/resources/elasticsearch/mappings/mapping-location.json @@ -15,7 +15,8 @@ }, "searchText": { "type": "text", - "analyzer": "korean" + "analyzer": "korean_ngram", + "search_analyzer": "korean_ngram" }, "latitude": { "type": "double" diff --git a/src/main/resources/elasticsearch/settings/setting.json b/src/main/resources/elasticsearch/settings/setting.json index bb86056..39d6a0e 100644 --- a/src/main/resources/elasticsearch/settings/setting.json +++ b/src/main/resources/elasticsearch/settings/setting.json @@ -12,11 +12,23 @@ "nori_mixed": { "type": "nori_tokenizer", "decompound_mode": "mixed" + }, + "edge_ngram_tokenizer": { + "type": "edge_ngram", + "min_gram": 1, + "max_gram": 20, + "token_chars": ["letter", "digit"] } }, "analyzer": { "korean": { - "type": "nori"} + "type": "nori" + }, + "korean_ngram": { + "type": "custom", + "tokenizer": "edge_ngram_tokenizer", + "filter": ["lowercase"] + } } } }