diff --git a/src/main/kotlin/picklab/backend/activity/domain/repository/ActivityRepositoryImpl.kt b/src/main/kotlin/picklab/backend/activity/domain/repository/ActivityRepositoryImpl.kt index 97f76688..4c947ec0 100644 --- a/src/main/kotlin/picklab/backend/activity/domain/repository/ActivityRepositoryImpl.kt +++ b/src/main/kotlin/picklab/backend/activity/domain/repository/ActivityRepositoryImpl.kt @@ -194,6 +194,13 @@ class ActivityRepositoryImpl( jobGroups?.let { and(QJobCategory.jobCategory.jobGroup.`in`(it)) } } + val daysUntilDeadline = + Expressions.numberTemplate( + Long::class.java, + "DATEDIFF({0}, {1})", + QActivity.activity.recruitmentEndDate, + LocalDate.now(), + ) val orderBy = when (sort) { ActivitySortType.LATEST -> { @@ -209,36 +216,54 @@ class ActivityRepositoryImpl( ActivitySortType.DEADLINE_DESC -> { listOf( - Expressions - .numberTemplate( - Long::class.java, - "DATEDIFF({0}, {1})", - QActivity.activity.recruitmentEndDate, - LocalDate.now(), - ).desc(), + daysUntilDeadline.desc(), QActivity.activity.createdAt.desc(), ) } } val activityIds = - jpaQueryFactory - .select(QActivity.activity.id) - .distinct() - .from(QActivity.activity) - .leftJoin(QActivityJobCategory.activityJobCategory) - .on( - QActivityJobCategory.activityJobCategory.activity.id - .eq(QActivity.activity.id), - ).leftJoin(QJobCategory.jobCategory) - .on( - QActivityJobCategory.activityJobCategory.jobCategory.id - .eq(QJobCategory.jobCategory.id), - ).where(condition) - .orderBy(*orderBy.toTypedArray()) - .offset(pageable.offset) - .limit(pageable.pageSize.toLong()) - .fetch() + when (sort) { + ActivitySortType.DEADLINE_DESC -> + jpaQueryFactory + .select(QActivity.activity, daysUntilDeadline) + .distinct() + .from(QActivity.activity) + .leftJoin(QActivityJobCategory.activityJobCategory) + .on( + QActivityJobCategory.activityJobCategory.activity.id + .eq(QActivity.activity.id), + ).leftJoin(QJobCategory.jobCategory) + .on( + QActivityJobCategory.activityJobCategory.jobCategory.id + .eq(QJobCategory.jobCategory.id), + ).where(condition) + .orderBy(*orderBy.toTypedArray()) + .offset(pageable.offset) + .limit(pageable.pageSize.toLong()) + .fetch() + .mapNotNull { it.get(QActivity.activity)?.id } + + else -> + jpaQueryFactory + .select(QActivity.activity) + .distinct() + .from(QActivity.activity) + .leftJoin(QActivityJobCategory.activityJobCategory) + .on( + QActivityJobCategory.activityJobCategory.activity.id + .eq(QActivity.activity.id), + ).leftJoin(QJobCategory.jobCategory) + .on( + QActivityJobCategory.activityJobCategory.jobCategory.id + .eq(QJobCategory.jobCategory.id), + ).where(condition) + .orderBy(*orderBy.toTypedArray()) + .offset(pageable.offset) + .limit(pageable.pageSize.toLong()) + .fetch() + .map { it.id } + } if (activityIds.isEmpty()) { return PageImpl(emptyList(), pageable, 0) diff --git a/src/main/kotlin/picklab/backend/common/config/SecurityConfig.kt b/src/main/kotlin/picklab/backend/common/config/SecurityConfig.kt index bfeb7632..b8217ea5 100644 --- a/src/main/kotlin/picklab/backend/common/config/SecurityConfig.kt +++ b/src/main/kotlin/picklab/backend/common/config/SecurityConfig.kt @@ -32,7 +32,9 @@ class SecurityConfig( "/swagger", "/v1/auth/login/*", "/v1/activities", + "/v1/search", "/v1/search/autocomplete", + "/v1/search/popular-keywords", "/v1/activities/*/reviews/statistics/**", ) diff --git a/src/main/kotlin/picklab/backend/common/model/SuccessCode.kt b/src/main/kotlin/picklab/backend/common/model/SuccessCode.kt index 83adf1d4..069c880a 100644 --- a/src/main/kotlin/picklab/backend/common/model/SuccessCode.kt +++ b/src/main/kotlin/picklab/backend/common/model/SuccessCode.kt @@ -75,6 +75,7 @@ enum class SuccessCode( SEARCH_HISTORY_CREATED(HttpStatus.CREATED, "검색 기록을 생성했습니다."), SEARCH_HISTORY_RETRIEVED(HttpStatus.OK, "검색 기록 조회에 성공했습니다."), RECENT_KEYWORDS_RETRIEVED(HttpStatus.OK, "최근 검색어 조회에 성공했습니다."), + POPULAR_SEARCH_KEYWORDS_RETRIEVED(HttpStatus.OK, "인기 검색어 조회에 성공했습니다."), SEARCH_HISTORY_DELETED(HttpStatus.OK, "검색 기록을 삭제했습니다."), SEARCH_HISTORY_ALL_DELETED(HttpStatus.OK, "모든 검색 기록을 삭제했습니다."), diff --git a/src/main/kotlin/picklab/backend/search/application/SearchUseCase.kt b/src/main/kotlin/picklab/backend/search/application/SearchUseCase.kt index 78fa1bdd..64f23ef2 100644 --- a/src/main/kotlin/picklab/backend/search/application/SearchUseCase.kt +++ b/src/main/kotlin/picklab/backend/search/application/SearchUseCase.kt @@ -13,7 +13,9 @@ import picklab.backend.activity.domain.service.ActivityBookmarkService import picklab.backend.activity.domain.service.ActivityService import picklab.backend.job.domain.enums.JobGroup import picklab.backend.member.domain.MemberService +import picklab.backend.search.domain.model.PopularSearchKeywords import picklab.backend.search.domain.service.MemberSearchHistoryService +import picklab.backend.search.domain.service.PopularSearchKeywordService import picklab.backend.search.entrypoint.response.AutocompleteResponse import picklab.backend.search.entrypoint.response.RecentKeywordItem import picklab.backend.search.entrypoint.response.RecentKeywordsResponse @@ -27,6 +29,7 @@ class SearchUseCase( private val activityBookmarkService: ActivityBookmarkService, private val memberService: MemberService, private val memberSearchHistoryService: MemberSearchHistoryService, + private val popularSearchKeywordService: PopularSearchKeywordService, ) { /** * 활동명 자동완성 검색 @@ -46,6 +49,7 @@ class SearchUseCase( fun search( keyword: String, memberId: Long?, + searcherKey: String, ): SearchResultResponse { val trimmed = keyword.trim() val countPerType = activityService.countActivitiesByKeywordPerType(trimmed) @@ -79,6 +83,12 @@ class SearchUseCase( ) } + popularSearchKeywordService.recordSearch( + keyword = trimmed, + searcherKey = searcherKey, + totalCount = totalCount, + ) + return SearchResultResponse( keyword = trimmed, totalCount = totalCount, @@ -86,6 +96,8 @@ class SearchUseCase( ) } + fun getPopularKeywords(): PopularSearchKeywords = popularSearchKeywordService.getPopularKeywords() + /** * 카테고리별 검색 결과 페이지네이션 (카테고리 탭) */ diff --git a/src/main/kotlin/picklab/backend/search/domain/entity/BlockedSearchKeyword.kt b/src/main/kotlin/picklab/backend/search/domain/entity/BlockedSearchKeyword.kt new file mode 100644 index 00000000..6748cfb0 --- /dev/null +++ b/src/main/kotlin/picklab/backend/search/domain/entity/BlockedSearchKeyword.kt @@ -0,0 +1,15 @@ +package picklab.backend.search.domain.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table +import org.hibernate.annotations.Comment +import picklab.backend.common.model.BaseEntity + +@Entity +@Table(name = "blocked_search_keyword") +class BlockedSearchKeyword( + @Column(name = "keyword", nullable = false, unique = true, length = 255) + @Comment("정규화된 차단 검색어") + val keyword: String, +) : BaseEntity() diff --git a/src/main/kotlin/picklab/backend/search/domain/entity/PopularSearchKeywordEvent.kt b/src/main/kotlin/picklab/backend/search/domain/entity/PopularSearchKeywordEvent.kt new file mode 100644 index 00000000..cda79044 --- /dev/null +++ b/src/main/kotlin/picklab/backend/search/domain/entity/PopularSearchKeywordEvent.kt @@ -0,0 +1,34 @@ +package picklab.backend.search.domain.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import org.hibernate.annotations.Comment +import picklab.backend.common.model.BaseEntity +import java.time.LocalDateTime + +@Entity +@Table( + name = "popular_search_keyword_event", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_popular_search_keyword_event_hour", + columnNames = ["keyword", "searcher_key", "search_hour"], + ), + ], +) +class PopularSearchKeywordEvent( + @Column(name = "keyword", nullable = false, length = 255) + @Comment("정규화된 검색 키워드") + val keyword: String, + @Column(name = "searcher_key", nullable = false, length = 100) + @Comment("검색자 식별 키") + val searcherKey: String, + @Column(name = "search_hour", nullable = false) + @Comment("검색 집계 시간대") + val searchHour: LocalDateTime, + @Column(name = "searched_at", nullable = false) + @Comment("검색 실행 시간") + val searchedAt: LocalDateTime, +) : BaseEntity() diff --git a/src/main/kotlin/picklab/backend/search/domain/enums/PopularSearchKeywordTrend.kt b/src/main/kotlin/picklab/backend/search/domain/enums/PopularSearchKeywordTrend.kt new file mode 100644 index 00000000..79775dac --- /dev/null +++ b/src/main/kotlin/picklab/backend/search/domain/enums/PopularSearchKeywordTrend.kt @@ -0,0 +1,8 @@ +package picklab.backend.search.domain.enums + +enum class PopularSearchKeywordTrend { + UP, + DOWN, + SAME, + NEW, +} diff --git a/src/main/kotlin/picklab/backend/search/domain/model/PopularSearchKeywords.kt b/src/main/kotlin/picklab/backend/search/domain/model/PopularSearchKeywords.kt new file mode 100644 index 00000000..204d1c3d --- /dev/null +++ b/src/main/kotlin/picklab/backend/search/domain/model/PopularSearchKeywords.kt @@ -0,0 +1,15 @@ +package picklab.backend.search.domain.model + +import picklab.backend.search.domain.enums.PopularSearchKeywordTrend +import java.time.LocalDateTime + +data class PopularSearchKeywords( + val aggregatedAt: LocalDateTime, + val keywords: List, +) + +data class PopularSearchKeyword( + val rank: Int, + val keyword: String, + val trend: PopularSearchKeywordTrend, +) diff --git a/src/main/kotlin/picklab/backend/search/domain/repository/BlockedSearchKeywordRepository.kt b/src/main/kotlin/picklab/backend/search/domain/repository/BlockedSearchKeywordRepository.kt new file mode 100644 index 00000000..88718cc2 --- /dev/null +++ b/src/main/kotlin/picklab/backend/search/domain/repository/BlockedSearchKeywordRepository.kt @@ -0,0 +1,8 @@ +package picklab.backend.search.domain.repository + +import org.springframework.data.jpa.repository.JpaRepository +import picklab.backend.search.domain.entity.BlockedSearchKeyword + +interface BlockedSearchKeywordRepository : JpaRepository { + fun existsByKeyword(keyword: String): Boolean +} diff --git a/src/main/kotlin/picklab/backend/search/domain/repository/PopularSearchKeywordEventRepository.kt b/src/main/kotlin/picklab/backend/search/domain/repository/PopularSearchKeywordEventRepository.kt new file mode 100644 index 00000000..827a729f --- /dev/null +++ b/src/main/kotlin/picklab/backend/search/domain/repository/PopularSearchKeywordEventRepository.kt @@ -0,0 +1,53 @@ +package picklab.backend.search.domain.repository + +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 org.springframework.data.repository.query.Param +import picklab.backend.search.domain.entity.PopularSearchKeywordEvent +import java.time.LocalDateTime + +interface PopularSearchKeywordEventRepository : JpaRepository { + @Modifying + @Query( + value = + """ + INSERT IGNORE INTO popular_search_keyword_event + (keyword, searcher_key, search_hour, searched_at, created_at, updated_at) + VALUES + (:keyword, :searcherKey, :searchHour, :searchedAt, NOW(), NOW()) + """, + nativeQuery = true, + ) + fun insertIgnore( + @Param("keyword") keyword: String, + @Param("searcherKey") searcherKey: String, + @Param("searchHour") searchHour: LocalDateTime, + @Param("searchedAt") searchedAt: LocalDateTime, + ): Int + + @Query( + """ + SELECT + event.keyword AS keyword, + COUNT(event.id) AS searchCount, + MAX(event.searchedAt) AS lastSearchedAt + FROM PopularSearchKeywordEvent event + WHERE event.searchHour = :searchHour + AND NOT EXISTS ( + SELECT blocked.id + FROM BlockedSearchKeyword blocked + WHERE blocked.keyword = event.keyword + ) + GROUP BY event.keyword + HAVING COUNT(event.id) >= :minSearchCount + ORDER BY COUNT(event.id) DESC, MAX(event.searchedAt) DESC + """, + ) + fun findRanksBySearchHour( + @Param("searchHour") searchHour: LocalDateTime, + @Param("minSearchCount") minSearchCount: Long, + pageable: Pageable, + ): List +} diff --git a/src/main/kotlin/picklab/backend/search/domain/repository/PopularSearchKeywordRankProjection.kt b/src/main/kotlin/picklab/backend/search/domain/repository/PopularSearchKeywordRankProjection.kt new file mode 100644 index 00000000..0495b5fd --- /dev/null +++ b/src/main/kotlin/picklab/backend/search/domain/repository/PopularSearchKeywordRankProjection.kt @@ -0,0 +1,9 @@ +package picklab.backend.search.domain.repository + +import java.time.LocalDateTime + +interface PopularSearchKeywordRankProjection { + val keyword: String + val searchCount: Long + val lastSearchedAt: LocalDateTime +} diff --git a/src/main/kotlin/picklab/backend/search/domain/service/PopularSearchKeywordService.kt b/src/main/kotlin/picklab/backend/search/domain/service/PopularSearchKeywordService.kt new file mode 100644 index 00000000..e3a5a1dc --- /dev/null +++ b/src/main/kotlin/picklab/backend/search/domain/service/PopularSearchKeywordService.kt @@ -0,0 +1,97 @@ +package picklab.backend.search.domain.service + +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import picklab.backend.search.domain.enums.PopularSearchKeywordTrend +import picklab.backend.search.domain.model.PopularSearchKeyword +import picklab.backend.search.domain.model.PopularSearchKeywords +import picklab.backend.search.domain.repository.BlockedSearchKeywordRepository +import picklab.backend.search.domain.repository.PopularSearchKeywordEventRepository +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +@Service +class PopularSearchKeywordService( + private val popularSearchKeywordEventRepository: PopularSearchKeywordEventRepository, + private val blockedSearchKeywordRepository: BlockedSearchKeywordRepository, + private val searchKeywordNormalizer: SearchKeywordNormalizer, +) { + companion object { + private const val MIN_SEARCH_COUNT = 2L + private const val RANK_LIMIT = 10 + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun recordSearch( + keyword: String, + searcherKey: String, + totalCount: Long, + ) { + if (totalCount <= 0) return + + val normalizedKeyword = searchKeywordNormalizer.normalize(keyword) + if (normalizedKeyword.isBlank()) return + if (blockedSearchKeywordRepository.existsByKeyword(normalizedKeyword)) return + + val now = LocalDateTime.now() + popularSearchKeywordEventRepository.insertIgnore( + keyword = normalizedKeyword, + searcherKey = searcherKey, + searchHour = now.truncatedTo(ChronoUnit.HOURS), + searchedAt = now, + ) + } + + @Transactional(readOnly = true) + fun getPopularKeywords(): PopularSearchKeywords { + val aggregatedAt = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS) + val currentSearchHour = aggregatedAt.minusHours(1) + val previousSearchHour = currentSearchHour.minusHours(1) + val pageable = PageRequest.of(0, RANK_LIMIT) + + val currentRanks = + popularSearchKeywordEventRepository.findRanksBySearchHour( + searchHour = currentSearchHour, + minSearchCount = MIN_SEARCH_COUNT, + pageable = pageable, + ) + val previousRankByKeyword = + popularSearchKeywordEventRepository + .findRanksBySearchHour( + searchHour = previousSearchHour, + minSearchCount = MIN_SEARCH_COUNT, + pageable = pageable, + ).mapIndexed { index, row -> row.keyword to index + 1 } + .toMap() + + return PopularSearchKeywords( + aggregatedAt = aggregatedAt, + keywords = + currentRanks.mapIndexed { index, row -> + val currentRank = index + 1 + PopularSearchKeyword( + rank = currentRank, + keyword = row.keyword, + trend = + calculateTrend( + currentRank = currentRank, + previousRank = previousRankByKeyword[row.keyword], + ), + ) + }, + ) + } + + private fun calculateTrend( + currentRank: Int, + previousRank: Int?, + ): PopularSearchKeywordTrend = + when { + previousRank == null -> PopularSearchKeywordTrend.NEW + currentRank < previousRank -> PopularSearchKeywordTrend.UP + currentRank > previousRank -> PopularSearchKeywordTrend.DOWN + else -> PopularSearchKeywordTrend.SAME + } +} diff --git a/src/main/kotlin/picklab/backend/search/domain/service/SearchKeywordNormalizer.kt b/src/main/kotlin/picklab/backend/search/domain/service/SearchKeywordNormalizer.kt new file mode 100644 index 00000000..d7b53070 --- /dev/null +++ b/src/main/kotlin/picklab/backend/search/domain/service/SearchKeywordNormalizer.kt @@ -0,0 +1,9 @@ +package picklab.backend.search.domain.service + +import org.springframework.stereotype.Component +import java.util.Locale + +@Component +class SearchKeywordNormalizer { + fun normalize(keyword: String): String = keyword.trim().lowercase(Locale.ROOT) +} diff --git a/src/main/kotlin/picklab/backend/search/entrypoint/SearchApi.kt b/src/main/kotlin/picklab/backend/search/entrypoint/SearchApi.kt index c4c03a1b..3f9cf7b9 100644 --- a/src/main/kotlin/picklab/backend/search/entrypoint/SearchApi.kt +++ b/src/main/kotlin/picklab/backend/search/entrypoint/SearchApi.kt @@ -14,6 +14,7 @@ import picklab.backend.common.model.ResponseWrapper import picklab.backend.job.domain.enums.JobGroup import picklab.backend.search.entrypoint.request.CreateSearchHistoryRequest import picklab.backend.search.entrypoint.response.AutocompleteResponse +import picklab.backend.search.entrypoint.response.PopularSearchKeywordsResponse import picklab.backend.search.entrypoint.response.RecentKeywordsResponse import picklab.backend.search.entrypoint.response.SearchHistoryResponse import picklab.backend.search.entrypoint.response.SearchResultResponse @@ -83,6 +84,12 @@ interface SearchApi { limit: Int = 10, ): ResponseEntity> + @Operation( + summary = "인기 검색어 조회", + description = "직전 1시간 기준 인기 검색어 1~10위와 순위 변동을 조회합니다.", + ) + fun getPopularKeywords(): ResponseEntity> + @Operation(summary = "검색 기록 생성", description = "새로운 검색 기록을 생성합니다.") fun createSearchHistory( @AuthenticationPrincipal member: MemberPrincipal, diff --git a/src/main/kotlin/picklab/backend/search/entrypoint/SearchController.kt b/src/main/kotlin/picklab/backend/search/entrypoint/SearchController.kt index c22d84a2..2652d7ec 100644 --- a/src/main/kotlin/picklab/backend/search/entrypoint/SearchController.kt +++ b/src/main/kotlin/picklab/backend/search/entrypoint/SearchController.kt @@ -12,6 +12,8 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes import picklab.backend.activity.application.model.ActivityItemWithBookmark import picklab.backend.activity.domain.enums.ActivitySortType import picklab.backend.activity.domain.enums.RecruitmentStatus @@ -24,6 +26,7 @@ import picklab.backend.job.domain.enums.JobGroup import picklab.backend.search.application.SearchUseCase import picklab.backend.search.entrypoint.request.CreateSearchHistoryRequest import picklab.backend.search.entrypoint.response.AutocompleteResponse +import picklab.backend.search.entrypoint.response.PopularSearchKeywordsResponse import picklab.backend.search.entrypoint.response.RecentKeywordsResponse import picklab.backend.search.entrypoint.response.SearchHistoryResponse import picklab.backend.search.entrypoint.response.SearchResultResponse @@ -32,6 +35,7 @@ import picklab.backend.search.entrypoint.response.SearchResultResponse @RequestMapping("/v1/search") class SearchController( private val searchUseCase: SearchUseCase, + private val searcherKeyResolver: SearcherKeyResolver, ) : SearchApi { @GetMapping("") override fun search( @@ -39,10 +43,23 @@ class SearchController( ): ResponseEntity> { val authentication = SecurityContextHolder.getContext().authentication val memberId: Long? = (authentication?.principal as? MemberPrincipal)?.memberId - val response = searchUseCase.search(keyword, memberId) + val response = + searchUseCase.search( + keyword = keyword, + memberId = memberId, + searcherKey = searcherKeyResolver.resolve(memberId, currentRequest()), + ) return ResponseEntity.ok(ResponseWrapper.success(SuccessCode.SEARCH_SUCCESS, response)) } + @GetMapping("/popular-keywords") + override fun getPopularKeywords(): ResponseEntity> { + val response = PopularSearchKeywordsResponse.from(searchUseCase.getPopularKeywords()) + return ResponseEntity.ok( + ResponseWrapper.success(SuccessCode.POPULAR_SEARCH_KEYWORDS_RETRIEVED, response), + ) + } + @GetMapping("/activities") override fun searchActivities( @RequestParam keyword: String, @@ -138,4 +155,6 @@ class SearchController( ResponseWrapper.success(SuccessCode.SEARCH_HISTORY_ALL_DELETED), ) } + + private fun currentRequest() = (RequestContextHolder.currentRequestAttributes() as ServletRequestAttributes).request } diff --git a/src/main/kotlin/picklab/backend/search/entrypoint/SearcherKeyResolver.kt b/src/main/kotlin/picklab/backend/search/entrypoint/SearcherKeyResolver.kt new file mode 100644 index 00000000..217bac33 --- /dev/null +++ b/src/main/kotlin/picklab/backend/search/entrypoint/SearcherKeyResolver.kt @@ -0,0 +1,48 @@ +package picklab.backend.search.entrypoint + +import jakarta.servlet.http.HttpServletRequest +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.security.MessageDigest + +@Component +class SearcherKeyResolver( + @Value("\${app.search.guest-key-salt:picklab-search-guest-key}") private val guestKeySalt: String, +) { + companion object { + private const val MEMBER_PREFIX = "MEMBER:" + private const val GUEST_PREFIX = "GUEST:" + private const val X_FORWARDED_FOR = "X-Forwarded-For" + private const val X_REAL_IP = "X-Real-IP" + } + + fun resolve( + memberId: Long?, + request: HttpServletRequest, + ): String = + if (memberId != null) { + "$MEMBER_PREFIX$memberId" + } else { + "$GUEST_PREFIX${hash(resolveClientIp(request))}" + } + + private fun resolveClientIp(request: HttpServletRequest): String { + val forwardedFor = + request + .getHeader(X_FORWARDED_FOR) + ?.split(",") + ?.firstOrNull() + ?.trim() + + return forwardedFor + ?.takeIf { it.isNotBlank() } + ?: request.getHeader(X_REAL_IP)?.takeIf { it.isNotBlank() } + ?: request.remoteAddr + } + + private fun hash(value: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val bytes = digest.digest("$guestKeySalt:$value".toByteArray()) + return bytes.joinToString("") { "%02x".format(it) } + } +} diff --git a/src/main/kotlin/picklab/backend/search/entrypoint/response/PopularSearchKeywordsResponse.kt b/src/main/kotlin/picklab/backend/search/entrypoint/response/PopularSearchKeywordsResponse.kt new file mode 100644 index 00000000..84408684 --- /dev/null +++ b/src/main/kotlin/picklab/backend/search/entrypoint/response/PopularSearchKeywordsResponse.kt @@ -0,0 +1,41 @@ +package picklab.backend.search.entrypoint.response + +import io.swagger.v3.oas.annotations.media.Schema +import picklab.backend.search.domain.enums.PopularSearchKeywordTrend +import picklab.backend.search.domain.model.PopularSearchKeyword +import picklab.backend.search.domain.model.PopularSearchKeywords +import java.time.LocalDateTime + +@Schema(description = "인기 검색어 응답") +data class PopularSearchKeywordsResponse( + @field:Schema(description = "집계 기준 시각") + val aggregatedAt: LocalDateTime, + @field:Schema(description = "인기 검색어 목록") + val keywords: List, +) { + companion object { + fun from(result: PopularSearchKeywords): PopularSearchKeywordsResponse = + PopularSearchKeywordsResponse( + aggregatedAt = result.aggregatedAt, + keywords = result.keywords.map(PopularSearchKeywordItem::from), + ) + } +} + +data class PopularSearchKeywordItem( + @field:Schema(description = "순위") + val rank: Int, + @field:Schema(description = "검색어") + val keyword: String, + @field:Schema(description = "순위 변동") + val trend: PopularSearchKeywordTrend, +) { + companion object { + fun from(keyword: PopularSearchKeyword): PopularSearchKeywordItem = + PopularSearchKeywordItem( + rank = keyword.rank, + keyword = keyword.keyword, + trend = keyword.trend, + ) + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ee5349ab..7bab0bd7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,6 +27,8 @@ spring: enable: true app: + search: + guest-key-salt: ${SEARCH_GUEST_KEY_SALT:picklab-search-guest-key} notification: cleanup: enabled: true # 알림 정리 배치 작업 활성화 여부 @@ -150,4 +152,4 @@ oci: access-key: testAccessKey secret-key: testSecretKey storage: - bucket-name: test-bucket \ No newline at end of file + bucket-name: test-bucket diff --git a/src/main/resources/db/migration/V1.13__create_popular_search_keyword_tables.sql b/src/main/resources/db/migration/V1.13__create_popular_search_keyword_tables.sql new file mode 100644 index 00000000..a98882d5 --- /dev/null +++ b/src/main/resources/db/migration/V1.13__create_popular_search_keyword_tables.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS popular_search_keyword_event +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '인기 검색어 집계 이벤트 ID', + keyword VARCHAR(255) NOT NULL COMMENT '정규화된 검색 키워드', + searcher_key VARCHAR(100) NOT NULL COMMENT '검색자 식별 키', + search_hour DATETIME NOT NULL COMMENT '검색 집계 시간대', + searched_at DATETIME NOT NULL COMMENT '검색 실행 시간', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일', + CONSTRAINT uk_popular_search_keyword_event_hour + UNIQUE (keyword, searcher_key, search_hour) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='인기 검색어 집계 이벤트 테이블'; + +CREATE INDEX idx_popular_search_keyword_event_01 + ON popular_search_keyword_event (search_hour, keyword); + +CREATE INDEX idx_popular_search_keyword_event_02 + ON popular_search_keyword_event (search_hour, searched_at); + +CREATE TABLE IF NOT EXISTS blocked_search_keyword +( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '차단 검색어 ID', + keyword VARCHAR(255) NOT NULL COMMENT '정규화된 차단 검색어', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일', + CONSTRAINT uk_blocked_search_keyword UNIQUE (keyword) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 COMMENT ='인기 검색어 차단 키워드 테이블'; diff --git a/src/test/kotlin/picklab/backend/search/PopularSearchKeywordIntegrationTest.kt b/src/test/kotlin/picklab/backend/search/PopularSearchKeywordIntegrationTest.kt new file mode 100644 index 00000000..f3022367 --- /dev/null +++ b/src/test/kotlin/picklab/backend/search/PopularSearchKeywordIntegrationTest.kt @@ -0,0 +1,112 @@ +package picklab.backend.search + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.data.domain.PageRequest +import org.springframework.test.web.servlet.get +import picklab.backend.activity.domain.entity.ExternalActivity +import picklab.backend.activity.domain.enums.ActivityFieldType +import picklab.backend.activity.domain.enums.LocationType +import picklab.backend.activity.domain.enums.OrganizerType +import picklab.backend.activity.domain.enums.ParticipantType +import picklab.backend.activity.domain.enums.RecruitmentStatus +import picklab.backend.activity.domain.repository.ActivityRepository +import picklab.backend.activitygroup.domain.entity.ActivityGroup +import picklab.backend.activitygroup.domain.repository.ActivityGroupRepository +import picklab.backend.search.domain.repository.PopularSearchKeywordEventRepository +import picklab.backend.template.IntegrationTest +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +class PopularSearchKeywordIntegrationTest : IntegrationTest() { + @Autowired + lateinit var activityRepository: ActivityRepository + + @Autowired + lateinit var activityGroupRepository: ActivityGroupRepository + + @Autowired + lateinit var popularSearchKeywordEventRepository: PopularSearchKeywordEventRepository + + @BeforeEach + fun setUp() { + cleanUp.all() + } + + @Test + @DisplayName("비로그인 통합 검색 요청은 인기 검색어 집계 이벤트로 기록된다") + fun recordGuestSearchEvent() { + saveActivity("테스트 검색 활동") + + mockMvc + .get("/v1/search") { + param("keyword", " 테스트 ") + header("X-Forwarded-For", "203.0.113.10") + }.andExpect { status { isOk() } } + + val searchHour = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS) + val ranks = + popularSearchKeywordEventRepository.findRanksBySearchHour( + searchHour = searchHour, + minSearchCount = 1, + pageable = PageRequest.of(0, 10), + ) + val savedEvent = popularSearchKeywordEventRepository.findAll().single() + + assertThat(ranks).hasSize(1) + assertThat(ranks[0].keyword).isEqualTo("테스트") + assertThat(savedEvent.searcherKey).startsWith("GUEST:") + assertThat(savedEvent.searcherKey).doesNotContain("203.0.113.10") + } + + @Test + @DisplayName("자동완성 요청은 인기 검색어 집계 이벤트로 기록하지 않는다") + fun doNotRecordAutocomplete() { + saveActivity("테스트 검색 활동") + + mockMvc + .get("/v1/search/autocomplete") { + param("keyword", "테스트") + }.andExpect { status { isOk() } } + + assertThat(popularSearchKeywordEventRepository.findAll()).isEmpty() + } + + private fun saveActivity(title: String) { + val activityGroup = + activityGroupRepository.save( + ActivityGroup( + name = "테스트 그룹", + description = "테스트 그룹 설명", + ), + ) + + activityRepository.save( + ExternalActivity( + title = title, + organizer = "테스트 주최", + organizerType = OrganizerType.PUBLIC_ORGANIZATION, + targetAudience = ParticipantType.WORKER, + location = LocationType.SEOUL_INCHEON, + recruitmentStartDate = LocalDate.now().minusDays(1), + recruitmentEndDate = LocalDate.now().plusMonths(1), + startDate = LocalDate.now().plusMonths(2), + endDate = LocalDate.now().plusMonths(3), + status = RecruitmentStatus.OPEN, + viewCount = 0L, + duration = 30, + activityHomepageUrl = null, + activityApplicationUrl = null, + activityThumbnailUrl = null, + description = "테스트 설명", + benefit = "테스트 혜택", + activityGroup = activityGroup, + activityField = ActivityFieldType.MENTORING, + ), + ) + } +} diff --git a/src/test/kotlin/picklab/backend/search/PopularSearchKeywordServiceTest.kt b/src/test/kotlin/picklab/backend/search/PopularSearchKeywordServiceTest.kt new file mode 100644 index 00000000..21a69ba0 --- /dev/null +++ b/src/test/kotlin/picklab/backend/search/PopularSearchKeywordServiceTest.kt @@ -0,0 +1,114 @@ +package picklab.backend.search + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.data.domain.PageRequest +import org.springframework.test.web.servlet.get +import org.springframework.transaction.support.TransactionTemplate +import picklab.backend.search.domain.entity.BlockedSearchKeyword +import picklab.backend.search.domain.enums.PopularSearchKeywordTrend +import picklab.backend.search.domain.repository.BlockedSearchKeywordRepository +import picklab.backend.search.domain.repository.PopularSearchKeywordEventRepository +import picklab.backend.search.domain.service.PopularSearchKeywordService +import picklab.backend.template.IntegrationTest +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +class PopularSearchKeywordServiceTest : IntegrationTest() { + @Autowired + lateinit var popularSearchKeywordService: PopularSearchKeywordService + + @Autowired + lateinit var popularSearchKeywordEventRepository: PopularSearchKeywordEventRepository + + @Autowired + lateinit var blockedSearchKeywordRepository: BlockedSearchKeywordRepository + + @Autowired + lateinit var transactionTemplate: TransactionTemplate + + @BeforeEach + fun setUp() { + cleanUp.all() + } + + @Test + @DisplayName("같은 시간대 동일 검색자와 동일 키워드는 한 번만 기록한다") + fun recordOncePerSearchHour() { + popularSearchKeywordService.recordSearch(" React ", "MEMBER:1", totalCount = 3) + popularSearchKeywordService.recordSearch("react", "MEMBER:1", totalCount = 3) + popularSearchKeywordService.recordSearch("empty", "MEMBER:2", totalCount = 0) + + val searchHour = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS) + val ranks = + popularSearchKeywordEventRepository.findRanksBySearchHour( + searchHour = searchHour, + minSearchCount = 1, + pageable = PageRequest.of(0, 10), + ) + + assertThat(ranks).hasSize(1) + assertThat(ranks[0].keyword).isEqualTo("react") + assertThat(ranks[0].searchCount).isEqualTo(1) + } + + @Test + @DisplayName("직전 1시간 인기 검색어와 순위 변동을 조회한다") + fun getPopularKeywords() { + val aggregatedAt = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS) + val currentHour = aggregatedAt.minusHours(1) + val previousHour = currentHour.minusHours(1) + + blockedSearchKeywordRepository.save(BlockedSearchKeyword("blocked")) + + insertEvents("kotlin", currentHour, 3, currentHour.plusMinutes(10)) + insertEvents("java", currentHour, 2, currentHour.plusMinutes(20)) + insertEvents("spring", currentHour, 2, currentHour.plusMinutes(5)) + insertEvents("react", currentHour, 1, currentHour.plusMinutes(30)) + insertEvents("blocked", currentHour, 3, currentHour.plusMinutes(40)) + + insertEvents("java", previousHour, 3, previousHour.plusMinutes(10)) + insertEvents("kotlin", previousHour, 2, previousHour.plusMinutes(20)) + + val response = popularSearchKeywordService.getPopularKeywords() + + assertThat(response.aggregatedAt).isEqualTo(aggregatedAt) + assertThat(response.keywords.map { it.keyword }).containsExactly("kotlin", "java", "spring") + assertThat(response.keywords.map { it.rank }).containsExactly(1, 2, 3) + assertThat(response.keywords.map { it.trend }) + .containsExactly( + PopularSearchKeywordTrend.UP, + PopularSearchKeywordTrend.DOWN, + PopularSearchKeywordTrend.NEW, + ) + } + + @Test + @DisplayName("인기 검색어 조회 API는 인증 없이 호출할 수 있다") + fun getPopularKeywordsWithoutAuthentication() { + mockMvc + .get("/v1/search/popular-keywords") + .andExpect { status { isOk() } } + } + + private fun insertEvents( + keyword: String, + searchHour: LocalDateTime, + count: Int, + lastSearchedAt: LocalDateTime, + ) { + (1..count).forEach { index -> + transactionTemplate.executeWithoutResult { + popularSearchKeywordEventRepository.insertIgnore( + keyword = keyword, + searcherKey = "MEMBER:$keyword:$index", + searchHour = searchHour, + searchedAt = if (index == count) lastSearchedAt else searchHour.plusMinutes(index.toLong()), + ) + } + } + } +}