From f2654135ce07bab1a8261f582b7ec8dec38641da Mon Sep 17 00:00:00 2001 From: junhokim Date: Mon, 2 Mar 2026 22:38:16 +0900 Subject: [PATCH] feat: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 최신 검색어 저장 / 삭제 / 업데이트 - member Id 조회 --- build.gradle.kts | 6 +- .../in/request/LocationRecentSearchModel.kt | 11 +++ .../in/request/context/UserContext.kt | 8 ++ .../in/request/context/WithUserContext.kt | 9 ++ .../response/LocationRecentSearchResponse.kt | 12 +++ .../in/service/LocationRecentSearchService.kt | 82 +++++++++++++++++ .../in/service/LocationSearchService.kt | 27 +++++- .../in/usecase/LocationRecentSearchUseCase.kt | 14 +++ .../in/usecase/LocationSearchUseCase.kt | 10 +-- .../LocationRecentSearchRepository.kt | 35 ++++++++ .../LocationSearchHistoryRepository.kt | 8 ++ .../map/domain/entity/LocationRecentSearch.kt | 61 +++++++++++++ .../domain/entity/LocationSearchHistory.kt | 37 ++++++++ .../map/domain/exception/common/ErrorCode.kt | 1 + .../in/event/LocationRecentSearchEvent.kt | 20 +++++ .../presentation/LocationSearchController.kt | 32 ------- .../filter/AuthenticationFilter.kt | 89 +++++++++++++++++++ .../resolver/UserContextArgumentResolver.kt | 24 +++++ .../{ => rest}/LocationController.kt | 10 +-- .../{ => rest}/LocationDetailController.kt | 4 +- .../LocationDetailSearchController.kt | 6 +- .../rest/LocationSearchController.kt | 60 +++++++++++++ .../retrip/map/infra/config/SwaggerConfig.kt | 29 ++++++ .../com/retrip/map/infra/config/WebConfig.kt | 23 +++++ src/main/resources/application-prod.yml | 11 +++ src/main/resources/application.yml | 20 ++++- 26 files changed, 587 insertions(+), 62 deletions(-) create mode 100644 src/main/kotlin/com/retrip/map/application/in/request/LocationRecentSearchModel.kt create mode 100644 src/main/kotlin/com/retrip/map/application/in/request/context/UserContext.kt create mode 100644 src/main/kotlin/com/retrip/map/application/in/request/context/WithUserContext.kt create mode 100644 src/main/kotlin/com/retrip/map/application/in/response/LocationRecentSearchResponse.kt create mode 100644 src/main/kotlin/com/retrip/map/application/in/service/LocationRecentSearchService.kt create mode 100644 src/main/kotlin/com/retrip/map/application/in/usecase/LocationRecentSearchUseCase.kt create mode 100644 src/main/kotlin/com/retrip/map/application/out/repository/LocationRecentSearchRepository.kt create mode 100644 src/main/kotlin/com/retrip/map/application/out/repository/LocationSearchHistoryRepository.kt create mode 100644 src/main/kotlin/com/retrip/map/domain/entity/LocationRecentSearch.kt create mode 100644 src/main/kotlin/com/retrip/map/domain/entity/LocationSearchHistory.kt create mode 100644 src/main/kotlin/com/retrip/map/infra/adapter/in/event/LocationRecentSearchEvent.kt delete mode 100644 src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/LocationSearchController.kt create mode 100644 src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/filter/AuthenticationFilter.kt create mode 100644 src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/resolver/UserContextArgumentResolver.kt rename src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/{ => rest}/LocationController.kt (83%) rename src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/{ => rest}/LocationDetailController.kt (96%) rename src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/{ => rest}/LocationDetailSearchController.kt (85%) create mode 100644 src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/rest/LocationSearchController.kt create mode 100644 src/main/kotlin/com/retrip/map/infra/config/SwaggerConfig.kt create mode 100644 src/main/kotlin/com/retrip/map/infra/config/WebConfig.kt create mode 100644 src/main/resources/application-prod.yml diff --git a/build.gradle.kts b/build.gradle.kts index 4465da7..27aba27 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,7 +2,7 @@ plugins { kotlin("jvm") version "1.9.25" kotlin("plugin.spring") version "1.9.25" kotlin("kapt") version "1.9.25" // 추가 - + kotlin("plugin.jpa") version "1.9.25" // 현재 사용 중인 코틀린 버전에 맞게 id("org.springframework.boot") version "3.5.6" id("io.spring.dependency-management") version "1.1.7" } @@ -35,6 +35,10 @@ dependencies { implementation("org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.5") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.5") implementation("com.querydsl:querydsl-jpa:5.1.0:jakarta") + implementation("io.jsonwebtoken:jjwt-api:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") + runtimeOnly("com.h2database:h2") kapt("com.querydsl:querydsl-apt:5.1.0:jakarta") // annotationProcessor → kapt로 변경 diff --git a/src/main/kotlin/com/retrip/map/application/in/request/LocationRecentSearchModel.kt b/src/main/kotlin/com/retrip/map/application/in/request/LocationRecentSearchModel.kt new file mode 100644 index 0000000..cde6f2c --- /dev/null +++ b/src/main/kotlin/com/retrip/map/application/in/request/LocationRecentSearchModel.kt @@ -0,0 +1,11 @@ +package com.retrip.map.application.`in`.request + +import java.time.LocalDateTime +import java.util.UUID + +data class LocationRecentSearchModel( + val memberId: UUID, + val searchText: String, + val updateTime: LocalDateTime = LocalDateTime.now(), +) { +} diff --git a/src/main/kotlin/com/retrip/map/application/in/request/context/UserContext.kt b/src/main/kotlin/com/retrip/map/application/in/request/context/UserContext.kt new file mode 100644 index 0000000..b54a17e --- /dev/null +++ b/src/main/kotlin/com/retrip/map/application/in/request/context/UserContext.kt @@ -0,0 +1,8 @@ +package com.retrip.map.application.`in`.request.context + +import java.util.UUID + +data class UserContext( + val memberId: UUID, +) { +} diff --git a/src/main/kotlin/com/retrip/map/application/in/request/context/WithUserContext.kt b/src/main/kotlin/com/retrip/map/application/in/request/context/WithUserContext.kt new file mode 100644 index 0000000..d2f841d --- /dev/null +++ b/src/main/kotlin/com/retrip/map/application/in/request/context/WithUserContext.kt @@ -0,0 +1,9 @@ +package com.retrip.map.application.`in`.request.context + +import io.swagger.v3.oas.annotations.Parameter + + +@Retention(AnnotationRetention.RUNTIME) +@Target(allowedTargets = [AnnotationTarget.VALUE_PARAMETER]) +@Parameter(hidden = true) +annotation class WithUserContext() diff --git a/src/main/kotlin/com/retrip/map/application/in/response/LocationRecentSearchResponse.kt b/src/main/kotlin/com/retrip/map/application/in/response/LocationRecentSearchResponse.kt new file mode 100644 index 0000000..97c72f2 --- /dev/null +++ b/src/main/kotlin/com/retrip/map/application/in/response/LocationRecentSearchResponse.kt @@ -0,0 +1,12 @@ +package com.retrip.map.application.`in`.response + +import com.retrip.map.domain.vo.LocationCountry +import io.swagger.v3.oas.annotations.media.Schema +import java.util.UUID + +@Schema(description = "장소 최근 조회 Response") +data class LocationRecentSearchResponse( + @Schema(description = "검색어") + val searchTexts: List? = null, +) { +} diff --git a/src/main/kotlin/com/retrip/map/application/in/service/LocationRecentSearchService.kt b/src/main/kotlin/com/retrip/map/application/in/service/LocationRecentSearchService.kt new file mode 100644 index 0000000..8a5e66e --- /dev/null +++ b/src/main/kotlin/com/retrip/map/application/in/service/LocationRecentSearchService.kt @@ -0,0 +1,82 @@ +package com.retrip.map.application.`in`.service + +import com.retrip.map.application.`in`.request.LocationRecentSearchModel +import com.retrip.map.application.`in`.request.context.UserContext +import com.retrip.map.application.`in`.response.LocationRecentSearchResponse +import com.retrip.map.application.`in`.usecase.LocationRecentSearchUseCase +import com.retrip.map.application.out.repository.LocationRecentSearchRepository +import com.retrip.map.domain.entity.LocationRecentSearch +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.Transactional + +@Service +@RequiredArgsConstructor +@Transactional +class LocationRecentSearchService( + private val locationRecentSearchRepository: LocationRecentSearchRepository +) : LocationRecentSearchUseCase { + + override fun getRecentLocation(context: UserContext): LocationRecentSearchResponse? { + val pageRequest = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "lastSearchedAt")) + //최대 10개만 조회 + val result = locationRecentSearchRepository.findByMemberId(context.memberId, pageRequest) + return result?.let { + LocationRecentSearchResponse( + it.map { recentSearch -> recentSearch.keyword } + ) + } + + } + + override fun addLocationRecentSearch(locationRecentSearchModel: LocationRecentSearchModel) { + val existingLocationRecentSearch = locationRecentSearchRepository.findByMemberIdAndKeyword( + locationRecentSearchModel.memberId, + locationRecentSearchModel.searchText, + ) + if (existingLocationRecentSearch != null) { + existingLocationRecentSearch.updateTime( + locationRecentSearchModel.updateTime + ) + } else { + locationRecentSearchRepository.save( + LocationRecentSearch.create( + locationRecentSearchModel.memberId, + locationRecentSearchModel.searchText, + locationRecentSearchModel.updateTime + ) + ) + } + val pageRequest = PageRequest.of(9, 1, Sort.by(Sort.Direction.DESC, "lastSearchedAt")) + val threshold = locationRecentSearchRepository.findThresholdTime( + locationRecentSearchModel.memberId, + pageRequest + )?.firstOrNull() + + threshold?.let { + locationRecentSearchRepository.deleteOlderThan( + locationRecentSearchModel.memberId, it + ) + } + } + + override fun delectRecentLocationsByKeyword(context: UserContext, keyword: String?) { + if (keyword != null) { + //단건 제거 + val recentSearchKeyword = + locationRecentSearchRepository.findByMemberIdAndKeyword(context.memberId, keyword) + recentSearchKeyword?.run { + locationRecentSearchRepository.delete(this) + } + } else { + //모두 제거 + val recentSearchKeywords = locationRecentSearchRepository.findByMemberId(context.memberId) + recentSearchKeywords?.run { + locationRecentSearchRepository.deleteAllInBatch(this) + } + } + } +} + diff --git a/src/main/kotlin/com/retrip/map/application/in/service/LocationSearchService.kt b/src/main/kotlin/com/retrip/map/application/in/service/LocationSearchService.kt index 68303a4..9ed017b 100644 --- a/src/main/kotlin/com/retrip/map/application/in/service/LocationSearchService.kt +++ b/src/main/kotlin/com/retrip/map/application/in/service/LocationSearchService.kt @@ -1,24 +1,44 @@ package com.retrip.map.application.`in`.service +import com.retrip.map.application.`in`.request.LocationRecentSearchModel +import com.retrip.map.application.`in`.request.context.UserContext import com.retrip.map.application.`in`.response.LocationSearchResponse import com.retrip.map.application.`in`.usecase.LocationSearchUseCase import com.retrip.map.application.out.repository.LocationElasticRepository +import com.retrip.map.application.out.repository.LocationSearchHistoryRepository +import com.retrip.map.domain.entity.LocationSearchHistory import lombok.RequiredArgsConstructor +import org.springframework.context.ApplicationEventPublisher 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 @Service @RequiredArgsConstructor @Transactional class LocationSearchService( - val locationElasticRepository: LocationElasticRepository + val locationElasticRepository: LocationElasticRepository, + val locationSearchHistoryRepository: LocationSearchHistoryRepository, + val eventPublisher: ApplicationEventPublisher, ) : LocationSearchUseCase { - @Transactional(readOnly = true) - override fun getLocation(searchText: String?, page: Pageable): Page { + @Transactional + override fun getLocation(searchText: String?, page: Pageable, context: UserContext): Page { val locations = locationElasticRepository.findBySearchText(searchText, page) + if (!searchText.isNullOrBlank()) { + val memberId = context.memberId + val locationSearchHistory = + locationSearchHistoryRepository.save(LocationSearchHistory.create(searchText, memberId)) + eventPublisher.publishEvent( + LocationRecentSearchModel( + searchText = searchText, + memberId = memberId, + updateTime = locationSearchHistory.createdAt ?: LocalDateTime.now(), + ) + ) + } return locations.map { LocationSearchResponse( id = it.id, @@ -29,6 +49,5 @@ class LocationSearchService( ) } } - } diff --git a/src/main/kotlin/com/retrip/map/application/in/usecase/LocationRecentSearchUseCase.kt b/src/main/kotlin/com/retrip/map/application/in/usecase/LocationRecentSearchUseCase.kt new file mode 100644 index 0000000..0c50b21 --- /dev/null +++ b/src/main/kotlin/com/retrip/map/application/in/usecase/LocationRecentSearchUseCase.kt @@ -0,0 +1,14 @@ +package com.retrip.map.application.`in`.usecase + +import com.retrip.map.application.`in`.request.LocationRecentSearchModel +import com.retrip.map.application.`in`.request.context.UserContext +import com.retrip.map.application.`in`.response.LocationRecentSearchResponse +import com.retrip.map.application.`in`.response.LocationSearchResponse +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +interface LocationRecentSearchUseCase { + fun getRecentLocation( context: UserContext) : LocationRecentSearchResponse? + fun addLocationRecentSearch(locationRecentSearchModel: LocationRecentSearchModel) + fun delectRecentLocationsByKeyword(context: UserContext, keyword: String?) +} diff --git a/src/main/kotlin/com/retrip/map/application/in/usecase/LocationSearchUseCase.kt b/src/main/kotlin/com/retrip/map/application/in/usecase/LocationSearchUseCase.kt index af2993d..db8846e 100644 --- a/src/main/kotlin/com/retrip/map/application/in/usecase/LocationSearchUseCase.kt +++ b/src/main/kotlin/com/retrip/map/application/in/usecase/LocationSearchUseCase.kt @@ -1,15 +1,11 @@ package com.retrip.map.application.`in`.usecase -import com.retrip.map.application.`in`.request.LocationCreateRequest -import com.retrip.map.application.`in`.request.LocationUpdateRequest -import com.retrip.map.application.`in`.response.LocationCreateResponse -import com.retrip.map.application.`in`.response.LocationResponse +import com.retrip.map.application.`in`.request.context.UserContext +import com.retrip.map.application.`in`.response.LocationRecentSearchResponse import com.retrip.map.application.`in`.response.LocationSearchResponse -import com.retrip.map.application.`in`.response.LocationUpdateResponse import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable -import java.util.* interface LocationSearchUseCase { - fun getLocation(searchText: String?, page: Pageable): Page + fun getLocation(searchText: String?, page: Pageable, context: UserContext): Page } diff --git a/src/main/kotlin/com/retrip/map/application/out/repository/LocationRecentSearchRepository.kt b/src/main/kotlin/com/retrip/map/application/out/repository/LocationRecentSearchRepository.kt new file mode 100644 index 0000000..159f5d5 --- /dev/null +++ b/src/main/kotlin/com/retrip/map/application/out/repository/LocationRecentSearchRepository.kt @@ -0,0 +1,35 @@ +package com.retrip.map.application.out.repository + +import com.retrip.map.domain.entity.LocationRecentSearch +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 LocationRecentSearchRepository : JpaRepository { + + fun findByMemberIdAndKeyword(memberId: UUID, keyword: String): LocationRecentSearch? + fun findByMemberId(memberId: UUID, pageable: Pageable): List? + fun findByMemberId(memberId: UUID): List? + + @Query( + """ + SELECT r.lastSearchedAt + FROM LocationRecentSearch r + WHERE r.memberId = :memberId + ORDER BY r.lastSearchedAt DESC + """ + ) + fun findThresholdTime(memberId: UUID, pageable: Pageable): List? + + @Modifying + @Query( + """ + DELETE FROM LocationRecentSearch r WHERE r.memberId = :memberId AND r.lastSearchedAt < :threshold + """ + ) + fun deleteOlderThan(memberId: UUID, it: LocalDateTime) + +} diff --git a/src/main/kotlin/com/retrip/map/application/out/repository/LocationSearchHistoryRepository.kt b/src/main/kotlin/com/retrip/map/application/out/repository/LocationSearchHistoryRepository.kt new file mode 100644 index 0000000..9113822 --- /dev/null +++ b/src/main/kotlin/com/retrip/map/application/out/repository/LocationSearchHistoryRepository.kt @@ -0,0 +1,8 @@ +package com.retrip.map.application.out.repository + +import com.retrip.map.domain.entity.LocationSearchHistory +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface LocationSearchHistoryRepository: JpaRepository { +} diff --git a/src/main/kotlin/com/retrip/map/domain/entity/LocationRecentSearch.kt b/src/main/kotlin/com/retrip/map/domain/entity/LocationRecentSearch.kt new file mode 100644 index 0000000..3760635 --- /dev/null +++ b/src/main/kotlin/com/retrip/map/domain/entity/LocationRecentSearch.kt @@ -0,0 +1,61 @@ +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_recent_search", + uniqueConstraints = [UniqueConstraint( + name = "uk_location_user_keyword", + columnNames = ["keyword", "memberId"] + )], + indexes = [ + Index(name = "idx_user_recent_searched", columnList = "member_id, last_searched_at DESC") + ] +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Setter(value = AccessLevel.PROTECTED) +class LocationRecentSearch( + @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): LocationRecentSearch { + return LocationRecentSearch( + id = UUID.randomUUID(), + keyword = searchText, + memberId = memberId, + lastSearchedAt = updateTime + ) + } + } + +} diff --git a/src/main/kotlin/com/retrip/map/domain/entity/LocationSearchHistory.kt b/src/main/kotlin/com/retrip/map/domain/entity/LocationSearchHistory.kt new file mode 100644 index 0000000..ae45310 --- /dev/null +++ b/src/main/kotlin/com/retrip/map/domain/entity/LocationSearchHistory.kt @@ -0,0 +1,37 @@ +package com.retrip.map.domain.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Version +import lombok.AccessLevel +import lombok.NoArgsConstructor +import lombok.Setter +import java.util.* + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Setter(value = AccessLevel.PROTECTED) +class LocationSearchHistory( + @Id + @Column(columnDefinition = "varbinary(16)") + val id: UUID? = null, + + val searchText: String, + + val memberId: UUID, + + @Version + private val version: Long? = null, +): BaseEntity() { + companion object { + fun create(searchText: String, memberId: UUID): LocationSearchHistory { + return LocationSearchHistory( + id = UUID.randomUUID(), + searchText = searchText, + memberId = memberId, + ) + } + + } +} diff --git a/src/main/kotlin/com/retrip/map/domain/exception/common/ErrorCode.kt b/src/main/kotlin/com/retrip/map/domain/exception/common/ErrorCode.kt index 29967f4..33a3ef6 100644 --- a/src/main/kotlin/com/retrip/map/domain/exception/common/ErrorCode.kt +++ b/src/main/kotlin/com/retrip/map/domain/exception/common/ErrorCode.kt @@ -20,4 +20,5 @@ enum class ErrorCode( LOCATION_DETAIL_NOT_FOUND(BAD_REQUEST, "Location-002", "로케이션 디테일 엔티티를 찾을 수 없습니다."), LOCATION_DUPLICATION(INTERNAL_SERVER_ERROR, "Location-003", "지역이 중복됩니다."), LOCATION_DETAILS_DUPLICATION(INTERNAL_SERVER_ERROR, "Location-004", "상세 지역이 중복됩니다."), + } diff --git a/src/main/kotlin/com/retrip/map/infra/adapter/in/event/LocationRecentSearchEvent.kt b/src/main/kotlin/com/retrip/map/infra/adapter/in/event/LocationRecentSearchEvent.kt new file mode 100644 index 0000000..34b532f --- /dev/null +++ b/src/main/kotlin/com/retrip/map/infra/adapter/in/event/LocationRecentSearchEvent.kt @@ -0,0 +1,20 @@ +package com.retrip.map.infra.adapter.`in`.event + +import com.retrip.map.application.`in`.request.LocationRecentSearchModel +import com.retrip.map.application.`in`.usecase.LocationRecentSearchUseCase +import org.springframework.context.event.EventListener +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class LocationRecentSearchEvent( + private val locationRecentSearchUseCase: LocationRecentSearchUseCase +) { + @Async + @EventListener + @Transactional + fun locationRecentSearchEvent(locationRecentSearchModel: LocationRecentSearchModel) { + locationRecentSearchUseCase.addLocationRecentSearch(locationRecentSearchModel) + } +} diff --git a/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/LocationSearchController.kt b/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/LocationSearchController.kt deleted file mode 100644 index 8eb6b6f..0000000 --- a/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/LocationSearchController.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.retrip.map.infra.adapter.`in`.presentation - -import com.retrip.map.application.`in`.response.LocationSearchResponse -import com.retrip.map.application.`in`.usecase.LocationSearchUseCase -import com.retrip.map.infra.adapter.`in`.presentation.common.ApiResponse -import io.swagger.v3.oas.annotations.media.Schema -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.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestController - -@RestController -@RequiredArgsConstructor -@RequestMapping("/location-search") -class LocationSearchController( - private val locationSearchUseCase: LocationSearchUseCase -) { - - @GetMapping("") - @Schema(description = "장소 검색 엔진 조회") - fun getLocation( - @PageableDefault(size = 10, page = 0) page: Pageable, - @RequestParam(name = "searchText", required = false) searchText: String? - ): ApiResponse> { - val result = locationSearchUseCase.getLocation(searchText, page) - return ApiResponse.ok(result) - } -} diff --git a/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/filter/AuthenticationFilter.kt b/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/filter/AuthenticationFilter.kt new file mode 100644 index 0000000..933ec50 --- /dev/null +++ b/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/filter/AuthenticationFilter.kt @@ -0,0 +1,89 @@ +package com.retrip.map.infra.adapter.`in`.presentation.filter + +import com.retrip.map.application.`in`.request.context.UserContext +import io.jsonwebtoken.Claims +import io.jsonwebtoken.Jwts +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import java.security.KeyFactory +import java.security.interfaces.RSAPublicKey +import java.security.spec.X509EncodedKeySpec +import java.util.* + +@Component +class AuthenticationFilter : OncePerRequestFilter() { + @Value("\${jwt.public-key}") + private val publicKey: String? = null + + override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) { + val path = request.requestURI.lowercase() + + when { + path == "/" || + path.contains("swagger") || + path.contains("api-docs") || + path.contains("actuator") || + path.contains("robots.txt") || + path.contains("h2-console") || + path.contains("status-check") || + path.startsWith("/locations") || // 장소 등록 ADMIN + path.startsWith("/location-details") // 장소 상세 등록 ADMIN + -> { + filterChain.doFilter(request, response) + return + } + } + val token = resolveToken(request) + if (token.isNullOrBlank()) { + // Token 복호화 실패 + response.status = HttpServletResponse.SC_UNAUTHORIZED + return + } + try { + val claims = getClaims(token) + val subject = claims?.subject ?: throw IllegalStateException() + val memberId = UUID.fromString(subject) + + val userContext = UserContext(memberId) + request.setAttribute("userContext", userContext) + } catch (e: Exception) { + //Token 내 원하는 값 없음 + response.status = HttpServletResponse.SC_FORBIDDEN + return; + } + filterChain.doFilter(request, response) + + } + + + private fun resolveToken(req: HttpServletRequest): String? { + val bearerToken = req.getHeader("Authorization") + if (bearerToken.isNullOrBlank() || !bearerToken.startsWith("Bearer")) return null + return bearerToken.substring(7) + } + + private fun getClaims(token: String): Claims? { + val sanitizedKey = publicKey + ?.replace("-----BEGIN PUBLIC KEY-----", "") + ?.replace("-----END PUBLIC KEY-----", "") + ?.replace("\\s".toRegex(), "") + // 2. RSA 공개키 생성 (run 스코프 함수로 관련 로직 묶기) + val publicKey = KeyFactory.getInstance("RSA").run { + val publicBytes = Base64.getDecoder().decode(sanitizedKey) + val keySpec = X509EncodedKeySpec(publicBytes) + generatePublic(keySpec) + } + + return Jwts.parser() + .verifyWith(publicKey as RSAPublicKey) // 타입 캐스팅으로 명확성 확보 + .build() + .parseSignedClaims(token) + .payload + } + + +} diff --git a/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/resolver/UserContextArgumentResolver.kt b/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/resolver/UserContextArgumentResolver.kt new file mode 100644 index 0000000..ddc0677 --- /dev/null +++ b/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/resolver/UserContextArgumentResolver.kt @@ -0,0 +1,24 @@ +package com.retrip.map.infra.adapter.`in`.presentation.resolver + +import com.retrip.map.application.`in`.request.context.UserContext +import com.retrip.map.application.`in`.request.context.WithUserContext +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.context.request.RequestAttributes +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +@Component +class UserContextArgumentResolver: HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean { + return parameter.hasParameterAnnotation(WithUserContext::class.java) + } + + override fun resolveArgument(parameter: MethodParameter, mavContainer: ModelAndViewContainer?, webRequest: NativeWebRequest, binderFactory: WebDataBinderFactory?): UserContext { + val userContext = webRequest.getAttribute("userContext", RequestAttributes.SCOPE_REQUEST) as UserContext + return userContext + } + +} diff --git a/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/LocationController.kt b/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/rest/LocationController.kt similarity index 83% rename from src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/LocationController.kt rename to src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/rest/LocationController.kt index 52bb80c..d8da3ee 100644 --- a/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/LocationController.kt +++ b/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/rest/LocationController.kt @@ -1,16 +1,10 @@ -package com.retrip.map.infra.adapter.`in`.presentation +package com.retrip.map.infra.adapter.`in`.presentation.rest import com.retrip.map.application.`in`.request.LocationCreateRequest -import com.retrip.map.application.`in`.request.LocationDetailCreateRequest -import com.retrip.map.application.`in`.request.LocationDetailUpdateRequest import com.retrip.map.application.`in`.request.LocationUpdateRequest import com.retrip.map.application.`in`.response.LocationCreateResponse -import com.retrip.map.application.`in`.response.LocationDetailCreateResponse -import com.retrip.map.application.`in`.response.LocationDetailResponse -import com.retrip.map.application.`in`.response.LocationDetailUpdateResponse import com.retrip.map.application.`in`.response.LocationResponse import com.retrip.map.application.`in`.response.LocationUpdateResponse -import com.retrip.map.application.`in`.usecase.LocationDetailUseCase import com.retrip.map.application.`in`.usecase.LocationUseCase import com.retrip.map.infra.adapter.`in`.presentation.common.ApiResponse import io.swagger.v3.oas.annotations.media.Schema @@ -31,7 +25,7 @@ import java.util.* @RestController @RequiredArgsConstructor -@RequestMapping("/location") +@RequestMapping("/locations") class LocationController( private val locationUseCase: LocationUseCase ) { diff --git a/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/LocationDetailController.kt b/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/rest/LocationDetailController.kt similarity index 96% rename from src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/LocationDetailController.kt rename to src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/rest/LocationDetailController.kt index f2e8965..0fa98d4 100644 --- a/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/LocationDetailController.kt +++ b/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/rest/LocationDetailController.kt @@ -1,4 +1,4 @@ -package com.retrip.map.infra.adapter.`in`.presentation +package com.retrip.map.infra.adapter.`in`.presentation.rest import com.retrip.map.application.`in`.request.LocationDetailCreateRequest import com.retrip.map.application.`in`.request.LocationDetailUpdateRequest @@ -25,7 +25,7 @@ import java.util.* @RestController @RequiredArgsConstructor -@RequestMapping("/location-detail") +@RequestMapping("/location-details") class LocationDetailController( private val locationDetailUseCase: LocationDetailUseCase ) { diff --git a/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/LocationDetailSearchController.kt b/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/rest/LocationDetailSearchController.kt similarity index 85% rename from src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/LocationDetailSearchController.kt rename to src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/rest/LocationDetailSearchController.kt index 89f3493..32f3953 100644 --- a/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/LocationDetailSearchController.kt +++ b/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/rest/LocationDetailSearchController.kt @@ -1,9 +1,7 @@ -package com.retrip.map.infra.adapter.`in`.presentation +package com.retrip.map.infra.adapter.`in`.presentation.rest import com.retrip.map.application.`in`.response.LocationDetailSearchResponse -import com.retrip.map.application.`in`.response.LocationSearchResponse import com.retrip.map.application.`in`.usecase.LocationDetailSearchUseCase -import com.retrip.map.application.`in`.usecase.LocationSearchUseCase import com.retrip.map.infra.adapter.`in`.presentation.common.ApiResponse import io.swagger.v3.oas.annotations.media.Schema import lombok.RequiredArgsConstructor @@ -18,7 +16,7 @@ import java.util.UUID @RestController @RequiredArgsConstructor -@RequestMapping("/location-detail-search") +@RequestMapping("search/location-details") class LocationDetailSearchController( private val locationDetailSearchUseCase: LocationDetailSearchUseCase ) { diff --git a/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/rest/LocationSearchController.kt b/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/rest/LocationSearchController.kt new file mode 100644 index 0000000..b715dc9 --- /dev/null +++ b/src/main/kotlin/com/retrip/map/infra/adapter/in/presentation/rest/LocationSearchController.kt @@ -0,0 +1,60 @@ +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.LocationRecentSearchResponse +import com.retrip.map.application.`in`.response.LocationSearchResponse +import com.retrip.map.application.`in`.usecase.LocationRecentSearchUseCase +import com.retrip.map.application.`in`.usecase.LocationSearchUseCase +import com.retrip.map.infra.adapter.`in`.presentation.common.ApiResponse +import io.swagger.v3.oas.annotations.media.Schema +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 +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequiredArgsConstructor +@RequestMapping("search/locations") +class LocationSearchController( + private val locationSearchUseCase: LocationSearchUseCase, + private val locationRecentSearchUseCase: LocationRecentSearchUseCase +) { + + @GetMapping("") + @Schema(description = "장소 검색 엔진 조회") + fun getLocation( + @WithUserContext context: UserContext, + @PageableDefault(size = 10, page = 0) page: Pageable, + @RequestParam(name = "searchText", required = false) searchText: String? + ): ApiResponse> { + val result = locationSearchUseCase.getLocation(searchText, page, context) + return ApiResponse.ok(result) + } + + + @GetMapping("recent") + @Schema(description = "최근 장소 조회") + fun getRecentLocation( + @WithUserContext context: UserContext + ): ApiResponse { + val result = locationRecentSearchUseCase.getRecentLocation(context) + return ApiResponse.ok(result) + } + + @DeleteMapping("recent") + @Schema(description = "최근 장소 검색 제거") + fun delectRecentLocationsByKeyword( + @WithUserContext context: UserContext, + @RequestParam("keyword", required = false) keyword: String? + ): ApiResponse { + locationRecentSearchUseCase.delectRecentLocationsByKeyword(context, keyword) + return ApiResponse.noContent() + } + +} diff --git a/src/main/kotlin/com/retrip/map/infra/config/SwaggerConfig.kt b/src/main/kotlin/com/retrip/map/infra/config/SwaggerConfig.kt new file mode 100644 index 0000000..2328449 --- /dev/null +++ b/src/main/kotlin/com/retrip/map/infra/config/SwaggerConfig.kt @@ -0,0 +1,29 @@ +package com.retrip.map.infra.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import io.swagger.v3.oas.models.servers.Server +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class SwaggerConfig { + + @Bean + fun openApi(): OpenAPI? = OpenAPI() + .addSecurityItem(SecurityRequirement().addList("jwtAuth")) + .addServersItem(Server().url("/")) + .components( + Components() + .addSecuritySchemes( + "jwtAuth", + SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + ) + ) + +} diff --git a/src/main/kotlin/com/retrip/map/infra/config/WebConfig.kt b/src/main/kotlin/com/retrip/map/infra/config/WebConfig.kt new file mode 100644 index 0000000..a04af46 --- /dev/null +++ b/src/main/kotlin/com/retrip/map/infra/config/WebConfig.kt @@ -0,0 +1,23 @@ +package com.retrip.map.infra.config + +import com.retrip.map.infra.adapter.`in`.presentation.resolver.UserContextArgumentResolver +import org.springframework.context.annotation.Configuration +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebConfig( + private val userContextArgumentResolver: UserContextArgumentResolver +) : WebMvcConfigurer { + + override fun addResourceHandlers(registry: ResourceHandlerRegistry) { + registry + .addResourceHandler("/swagger-ui.html**") + .addResourceLocations("classpath:/static/") + } + + override fun addArgumentResolvers(resolvers: MutableList) { + resolvers.add(userContextArgumentResolver) + } +} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..f751e65 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,11 @@ +spring: + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + database-platform: org.hibernate.dialect.MySQLDialect + hibernate: + ddl-auto: update diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fcf843c..4199b2e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,10 +3,11 @@ spring: name: map ############# Data Source ###### datasource: - url: jdbc:mysql://localhost:3306/map?useSSL=false&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true - username: root - password: test1234 - driver-class-name: com.mysql.cj.jdbc.Driver + driver-class-name: org.h2.Driver + # url: jdbc:h2:mem:trip;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false;MODE=MySQL + url: jdbc:h2:tcp://localhost/~/map;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false;MODE=MySQL + username: sa + password: h2: console: enabled: true @@ -22,3 +23,14 @@ spring: show-sql: true hibernate: ddl-auto: create + +jwt: + public-key: | + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlfO/CoX9EzhskpAKX9ADH0wfEQZP4rAq + ptqq80W2YaOHAnXu+oU1UrP0b9ccKKSzMCVDwdmXrecZB0dFLPnoazMEbVOf6MSwrhGfxupPRmmJ + sIYgmQwo8/vnjaq/GYFfnHyCy6yKL41G+GZVqgeKdhr+w1jUw4L9Fs0l2J/AYqwTxZOnzxrU5erP + GSE5Dd3AwWt/brxuwA7sRfVS3mbbsYyYExjUrEbst8VtF3Pis35T8YfSDKMOUgiDnp30EAdGU1Up + u59J3+ToLRrIqIszRZqmasrWTL2/ihPO76PSTIMAsJMScjAwjUXA47YOOy6Vkzy8r3bPTYHp5C1N + KucWwwIDAQAB + -----END PUBLIC KEY-----