Skip to content

Commit

Permalink
Merge pull request #523 from Travel-in-nanaland/fix/#521-search
Browse files Browse the repository at this point in the history
[Fix] 검색 조건 수정
  • Loading branch information
heeeeeseok authored Dec 1, 2024
2 parents 1fa6735 + b1c9432 commit 5b0480f
Show file tree
Hide file tree
Showing 26 changed files with 1,974 additions and 313 deletions.
36 changes: 36 additions & 0 deletions src/main/java/com/jeju/nanaland/domain/common/dto/SearchDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.jeju.nanaland.domain.common.dto;

import com.querydsl.core.annotations.QueryProjection;
import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

@Data
@SuperBuilder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class SearchDto {

private Long id;
private String title;
private ImageFileDto firstImage;
private Long matchedCount;
private LocalDateTime createdAt;

@QueryProjection
public SearchDto(Long id, String title, String originUrl, String thumbnailUrl,
Long matchedCount, LocalDateTime createdAt) {
this.id = id;
this.title = title;
this.firstImage = new ImageFileDto(originUrl, thumbnailUrl);
this.matchedCount = matchedCount;
this.createdAt = createdAt;
}

public void addMatchedCount(Long count) {
this.matchedCount += count;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.jeju.nanaland.domain.experience.dto;

import com.jeju.nanaland.domain.common.dto.SearchDto;
import com.querydsl.core.annotations.QueryProjection;
import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

@Data
@SuperBuilder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ExperienceSearchDto extends SearchDto {

@QueryProjection
public ExperienceSearchDto(Long id, String title, String originUrl, String thumbnailUrl,
Long matchedCount,
LocalDateTime createdAt) {
super(id, title, originUrl, thumbnailUrl, matchedCount, createdAt);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.jeju.nanaland.domain.common.dto.PostPreviewDto;
import com.jeju.nanaland.domain.experience.dto.ExperienceCompositeDto;
import com.jeju.nanaland.domain.experience.dto.ExperienceResponse.ExperienceThumbnail;
import com.jeju.nanaland.domain.experience.dto.ExperienceSearchDto;
import com.jeju.nanaland.domain.experience.entity.enums.ExperienceType;
import com.jeju.nanaland.domain.experience.entity.enums.ExperienceTypeKeyword;
import com.jeju.nanaland.domain.review.dto.ReviewResponse.SearchPostForReviewDto;
Expand All @@ -20,9 +21,6 @@ public interface ExperienceRepositoryCustom {

ExperienceCompositeDto findCompositeDtoByIdWithPessimisticLock(Long id, Language language);

Page<ExperienceCompositeDto> searchCompositeDtoByKeyword(String keyword, Language language,
Pageable pageable);

Page<ExperienceThumbnail> findExperienceThumbnails(Language language,
ExperienceType experienceType, List<ExperienceTypeKeyword> keywordFilterList,
List<AddressTag> addressTags, Pageable pageable);
Expand All @@ -44,5 +42,9 @@ PopularPostPreviewDto findRandomPopularPostPreviewDtoByLanguage(Language languag

PopularPostPreviewDto findPostPreviewDtoByLanguageAndId(Language language, Long postId);

Page<ExperienceSearchDto> findSearchDtoByKeywordsUnion(List<String> keywords, Language language,
Pageable pageable);

Page<ExperienceSearchDto> findSearchDtoByKeywordsIntersect(List<String> keywords,
Language language, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,31 +16,43 @@
import com.jeju.nanaland.domain.common.dto.QPostPreviewDto;
import com.jeju.nanaland.domain.experience.dto.ExperienceCompositeDto;
import com.jeju.nanaland.domain.experience.dto.ExperienceResponse.ExperienceThumbnail;
import com.jeju.nanaland.domain.experience.dto.ExperienceSearchDto;
import com.jeju.nanaland.domain.experience.dto.QExperienceCompositeDto;
import com.jeju.nanaland.domain.experience.dto.QExperienceResponse_ExperienceThumbnail;
import com.jeju.nanaland.domain.experience.dto.QExperienceSearchDto;
import com.jeju.nanaland.domain.experience.entity.enums.ExperienceType;
import com.jeju.nanaland.domain.experience.entity.enums.ExperienceTypeKeyword;
import com.jeju.nanaland.domain.hashtag.entity.QKeyword;
import com.jeju.nanaland.domain.review.dto.QReviewResponse_SearchPostForReviewDto;
import com.jeju.nanaland.domain.review.dto.ReviewResponse.SearchPostForReviewDto;
import com.querydsl.core.Tuple;
import com.querydsl.core.group.GroupBy;
import com.querydsl.core.types.Expression;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.CaseBuilder;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.core.types.dsl.StringExpression;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.LockModeType;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.support.PageableExecutionUtils;

@RequiredArgsConstructor
public class ExperienceRepositoryImpl implements ExperienceRepositoryCustom {

private static final Logger log = LoggerFactory.getLogger(ExperienceRepositoryImpl.class);
private final JPAQueryFactory queryFactory;

@Override
Expand Down Expand Up @@ -100,54 +112,129 @@ public ExperienceCompositeDto findCompositeDtoByIdWithPessimisticLock(Long id,
}

@Override
public Page<ExperienceCompositeDto> searchCompositeDtoByKeyword(String keyword, Language language,
Pageable pageable) {
public Page<ExperienceSearchDto> findSearchDtoByKeywordsUnion(List<String> keywords,
Language language, Pageable pageable) {

List<Long> idListContainAllHashtags = getIdListContainAllHashtags(keyword, language);
// experience_id를 가진 게시물의 해시태그가 검색어 키워드 중 몇개를 포함하는지 계산
List<Tuple> keywordMatchQuery = queryFactory
.select(experience.id, experience.id.count())
.from(experience)
.leftJoin(hashtag)
.on(hashtag.post.id.eq(experience.id)
.and(hashtag.language.eq(language)))
.innerJoin(hashtag.keyword, QKeyword.keyword)
.where(QKeyword.keyword.content.toLowerCase().trim().in(keywords))
.groupBy(experience.id)
.fetch();

List<ExperienceCompositeDto> resultDto = queryFactory
.select(new QExperienceCompositeDto(
Map<Long, Long> keywordMatchMap = keywordMatchQuery.stream()
.collect(Collectors.toMap(
tuple -> tuple.get(experience.id), // key: experience_id
tuple -> tuple.get(experience.id.count()) // value: 매칭된 키워드 개수
));

List<ExperienceSearchDto> resultDto = queryFactory
.select(new QExperienceSearchDto(
experience.id,
experienceTrans.title,
imageFile.originUrl,
imageFile.thumbnailUrl,
experience.contact,
experience.homepage,
experienceTrans.language,
experienceTrans.title,
experienceTrans.content,
experienceTrans.address,
experienceTrans.addressTag,
experienceTrans.intro,
experienceTrans.details,
experienceTrans.time,
experienceTrans.amenity,
experienceTrans.fee
countMatchingWithKeyword(keywords), // 제목, 내용, 지역정보와 매칭되는 키워드 개수
experience.createdAt
))
.from(experience)
.leftJoin(experience.firstImageFile, imageFile)
.leftJoin(experience.experienceTrans, experienceTrans)
.on(experienceTrans.language.eq(language))
.where(experienceTrans.title.contains(keyword)
.or(experienceTrans.addressTag.contains(keyword))
.or(experienceTrans.content.contains(keyword))
.or(experience.id.in(idListContainAllHashtags)))
.orderBy(experienceTrans.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();

JPAQuery<Long> countQuery = queryFactory
.select(experience.count())
// 해시태그 값을 matchedCount에 더해줌
for (ExperienceSearchDto experienceSearchDto : resultDto) {
Long id = experienceSearchDto.getId();
experienceSearchDto.addMatchedCount(keywordMatchMap.getOrDefault(id, 0L));
}
// matchedCount가 0이라면 검색결과에서 제거
resultDto = resultDto.stream()
.filter(experienceSearchDto -> experienceSearchDto.getMatchedCount() > 0)
.toList();

// 매칭된 키워드 수 내림차순, 생성날짜 내림차순 정렬
List<ExperienceSearchDto> resultList = new ArrayList<>(resultDto);
resultList.sort(Comparator
.comparing(ExperienceSearchDto::getMatchedCount,
Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(ExperienceSearchDto::getCreatedAt,
Comparator.nullsLast(Comparator.reverseOrder())));

// 페이징 처리
int startIdx = pageable.getPageSize() * pageable.getPageNumber();
int endIdx = Math.min(startIdx + pageable.getPageSize(), resultList.size());
List<ExperienceSearchDto> finalList = resultList.subList(startIdx, endIdx);
final Long total = Long.valueOf(resultDto.size());

return PageableExecutionUtils.getPage(finalList, pageable, () -> total);
}

@Override
public Page<ExperienceSearchDto> findSearchDtoByKeywordsIntersect(List<String> keywords,
Language language, Pageable pageable) {

// experience_id를 가진 게시물의 해시태그가 검색어 키워드 중 몇개를 포함하는지 계산
List<Tuple> keywordMatchQuery = queryFactory
.select(experience.id, experience.id.count())
.from(experience)
.leftJoin(hashtag)
.on(hashtag.post.id.eq(experience.id)
.and(hashtag.language.eq(language)))
.innerJoin(hashtag.keyword, QKeyword.keyword)
.where(QKeyword.keyword.content.toLowerCase().trim().in(keywords))
.groupBy(experience.id)
.fetch();

Map<Long, Long> keywordMatchMap = keywordMatchQuery.stream()
.collect(Collectors.toMap(
tuple -> tuple.get(experience.id), // key: experience_id
tuple -> tuple.get(experience.id.count()) // value: 매칭된 키워드 개수
));

List<ExperienceSearchDto> resultDto = queryFactory
.select(new QExperienceSearchDto(
experience.id,
experienceTrans.title,
imageFile.originUrl,
imageFile.thumbnailUrl,
countMatchingWithKeyword(keywords), // 제목, 내용, 지역정보와 매칭되는 키워드 개수
experience.createdAt
))
.from(experience)
.leftJoin(experience.firstImageFile, imageFile)
.leftJoin(experience.experienceTrans, experienceTrans)
.on(experienceTrans.language.eq(language))
.where(experienceTrans.title.contains(keyword)
.or(experienceTrans.addressTag.contains(keyword))
.or(experienceTrans.content.contains(keyword))
.or(experience.id.in(idListContainAllHashtags)));
.fetch();

return PageableExecutionUtils.getPage(resultDto, pageable, countQuery::fetchOne);
// 해시태그 값을 matchedCount에 더해줌
for (ExperienceSearchDto experienceSearchDto : resultDto) {
Long id = experienceSearchDto.getId();
experienceSearchDto.addMatchedCount(keywordMatchMap.getOrDefault(id, 0L));
}
// matchedCount가 키워드 개수와 다르다면 검색결과에서 제거
resultDto = resultDto.stream()
.filter(experienceSearchDto -> experienceSearchDto.getMatchedCount() >= keywords.size())
.toList();

// 생성날짜 내림차순 정렬
List<ExperienceSearchDto> resultList = new ArrayList<>(resultDto);
resultList.sort(Comparator
.comparing(ExperienceSearchDto::getCreatedAt,
Comparator.nullsLast(Comparator.reverseOrder())));

// 페이징 처리
int startIdx = pageable.getPageSize() * pageable.getPageNumber();
int endIdx = Math.min(startIdx + pageable.getPageSize(), resultList.size());
List<ExperienceSearchDto> finalList = resultList.subList(startIdx, endIdx);
final Long total = Long.valueOf(resultDto.size());

return PageableExecutionUtils.getPage(finalList, pageable, () -> total);
}

@Override
Expand Down Expand Up @@ -326,17 +413,31 @@ public PopularPostPreviewDto findPostPreviewDtoByLanguageAndId(Language language
.fetchOne();
}

private List<Long> getIdListContainAllHashtags(String keyword, Language language) {
private List<Long> getIdListContainAllHashtags(String keywords, Language language) {
return queryFactory
.select(experience.id)
.from(experience)
.leftJoin(hashtag)
.on(hashtag.post.id.eq(experience.id)
.and(hashtag.category.eq(Category.EXPERIENCE))
.and(hashtag.language.eq(language)))
.where(hashtag.keyword.content.in(splitKeyword(keyword)))
.where(hashtag.keyword.content.toLowerCase().trim().in(keywords))
.groupBy(experience.id)
.having(experience.id.count().eq(splitKeyword(keyword).stream().count()))
.having(experience.id.count().eq(splitKeyword(keywords).stream().count()))
.fetch();
}

private List<Long> getIdListContainAllHashtags(List<String> keywords, Language language) {
return queryFactory
.select(experience.id)
.from(experience)
.leftJoin(hashtag)
.on(hashtag.post.id.eq(experience.id)
.and(hashtag.category.eq(Category.EXPERIENCE))
.and(hashtag.language.eq(language)))
.where(hashtag.keyword.content.toLowerCase().trim().in(keywords))
.groupBy(experience.id)
.having(experience.id.count().eq(keywords.stream().count()))
.fetch();
}

Expand Down Expand Up @@ -367,4 +468,35 @@ private BooleanExpression keywordCondition(List<ExperienceTypeKeyword> keywordFi
return experienceKeyword.experienceTypeKeyword.in(keywordFilterList);
}
}

private Expression<Long> countMatchingWithKeyword(List<String> keywords) {
return Expressions.asNumber(0L)
.add(countMatchingConditionWithKeyword(experienceTrans.title.toLowerCase().trim(), keywords,
0))
.add(countMatchingConditionWithKeyword(experienceTrans.addressTag.toLowerCase().trim(),
keywords, 0))
.add(countMatchingConditionWithKeyword(experienceTrans.content, keywords, 0));
}

private Expression<Integer> countMatchingConditionWithKeyword(StringExpression condition,
List<String> keywords, int idx) {
if (idx == keywords.size()) {
return Expressions.asNumber(0);
}

return new CaseBuilder()
.when(condition.contains(keywords.get(idx)))
.then(1)
.otherwise(0)
.add(countMatchingConditionWithKeyword(condition, keywords, idx + 1));
}

private BooleanExpression containsAllKeywords(StringExpression condition, List<String> keywords) {
BooleanExpression expression = null;
for (String keyword : keywords) {
BooleanExpression containsKeyword = condition.contains(keyword);
expression = (expression == null) ? containsKeyword : expression.and(containsKeyword);
}
return expression;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.jeju.nanaland.domain.festival.dto;

import com.jeju.nanaland.domain.common.dto.SearchDto;
import com.querydsl.core.annotations.QueryProjection;
import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

@Data
@SuperBuilder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class FestivalSearchDto extends SearchDto {

@QueryProjection
public FestivalSearchDto(Long id, String title, String originUrl, String thumbnailUrl,
Long matchedCount,
LocalDateTime createdAt) {
super(id, title, originUrl, thumbnailUrl, matchedCount, createdAt);
}
}
Loading

0 comments on commit 5b0480f

Please sign in to comment.