diff --git a/src/main/java/com/example/umc9th/domain/review/repository/ReviewPredicate.java b/src/main/java/com/example/umc9th/domain/review/repository/ReviewPredicate.java index 6c40bf5..0b07afa 100644 --- a/src/main/java/com/example/umc9th/domain/review/repository/ReviewPredicate.java +++ b/src/main/java/com/example/umc9th/domain/review/repository/ReviewPredicate.java @@ -4,16 +4,11 @@ import org.springframework.util.StringUtils; import static com.example.umc9th.domain.review.entity.QReview.review; -import static com.example.umc9th.domain.store.entity.QStore.store; public class ReviewPredicate { private ReviewPredicate() {} - public static BooleanExpression storeNameContains(String storeName) { - return StringUtils.hasText(storeName) ? store.name.contains(storeName) : null; - } - public static BooleanExpression starRange(Float minStar, Float maxStar) { if (minStar != null && maxStar != null) { return review.star.between(minStar, maxStar); diff --git a/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepositoryImpl.java b/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepositoryImpl.java index 804582d..3ee9308 100644 --- a/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepositoryImpl.java +++ b/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepositoryImpl.java @@ -17,7 +17,6 @@ import java.util.List; import static com.example.umc9th.domain.review.entity.QReview.review; -import static com.example.umc9th.domain.store.entity.QStore.store; import static com.example.umc9th.domain.user.entity.QUser.user; import static com.example.umc9th.domain.review.entity.QReviewImage.reviewImage; import static com.example.umc9th.domain.review.entity.QReviewReply.reviewReply; diff --git a/src/main/java/com/example/umc9th/domain/review/service/ReviewServiceImpl.java b/src/main/java/com/example/umc9th/domain/review/service/ReviewServiceImpl.java index 18473c1..ae6559f 100644 --- a/src/main/java/com/example/umc9th/domain/review/service/ReviewServiceImpl.java +++ b/src/main/java/com/example/umc9th/domain/review/service/ReviewServiceImpl.java @@ -3,8 +3,8 @@ import com.example.umc9th.domain.review.dto.ReviewResponseDto; import com.example.umc9th.domain.review.entity.Review; import com.example.umc9th.domain.review.repository.ReviewPredicate; -import com.example.umc9th.domain.review.repository.ReviewQueryDsl; import com.example.umc9th.domain.review.repository.ReviewRepository; +import com.example.umc9th.domain.store.repository.StorePredicate; import com.example.umc9th.domain.store.repository.StoreRepository; import com.example.umc9th.domain.user.repository.UserRepository; import com.example.umc9th.global.dto.CursorResponseDto; @@ -49,7 +49,7 @@ public CursorResponseDto getReviews(String storeName, // 1. 서비스에서 Predicate 조합 BooleanBuilder predicate = new BooleanBuilder(); predicate.and(ReviewPredicate.userIdEquals(1L));// todo: 로그인한 사용자 - predicate.and(ReviewPredicate.storeNameContains(storeName)); + predicate.and(StorePredicate.storeNameContains(storeName)); predicate.and(ReviewPredicate.starRange(minStar, maxStar)); // 2. Pageable 객체 생성 diff --git a/src/main/java/com/example/umc9th/domain/store/controller/StoreController.java b/src/main/java/com/example/umc9th/domain/store/controller/StoreController.java new file mode 100644 index 0000000..6acf51c --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/store/controller/StoreController.java @@ -0,0 +1,43 @@ +package com.example.umc9th.domain.store.controller; + +import com.example.umc9th.domain.store.dto.StoreResponseDto; +import com.example.umc9th.domain.store.service.StoreService; +import com.example.umc9th.global.dto.CursorResponseDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +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; + +import java.rmi.registry.Registry; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/stores") +@Tag(name = "가게") +public class StoreController { + private final StoreService storeService; + + @Operation( + summary = "가게 검색", + description = """ + 1-1. 지역 필터링: region 기반, 다중 선택 가능 + 1-2. 이름 검색: + - 공백 포함: 각 단어 포함 가게 합집합 조회 + - 공백 없음: 전체 검색어 포함 가게 조회 + 1-3. 정렬 조건: + - latest: 최신순 + - name: 가나다 → 영어 대문자 → 영어 소문자 → 특수문자, 이름 동일 시 최신순 + 1-4. 페이징: page + size (커서 기반 페이징 옵션 가능) + """) + @GetMapping + public CursorResponseDto searchStore(@RequestParam(required = false) String storeName, + @RequestParam(required = false) String region, + @RequestParam(required = false) Long cursorId, + @RequestParam(required = false, defaultValue = "10") Integer size, + @RequestParam String sortType){ + return storeService.searchStore(storeName, region, cursorId, size, sortType); + } +} diff --git a/src/main/java/com/example/umc9th/domain/store/dto/StoreResponseDto.java b/src/main/java/com/example/umc9th/domain/store/dto/StoreResponseDto.java new file mode 100644 index 0000000..a8af388 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/store/dto/StoreResponseDto.java @@ -0,0 +1,20 @@ +package com.example.umc9th.domain.store.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class StoreResponseDto { + private StoreResponseDto() {} + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SearchedStore{ + private Long id; + private String name; + private String address; + } +} diff --git a/src/main/java/com/example/umc9th/domain/store/repository/StorePredicate.java b/src/main/java/com/example/umc9th/domain/store/repository/StorePredicate.java new file mode 100644 index 0000000..fd25af3 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/store/repository/StorePredicate.java @@ -0,0 +1,31 @@ +package com.example.umc9th.domain.store.repository; + +import com.querydsl.core.types.dsl.BooleanExpression; +import org.springframework.util.StringUtils; + +import static com.example.umc9th.domain.store.entity.QStore.store; + +public class StorePredicate { + private StorePredicate() {} + + public static BooleanExpression storeNameContains(String queryString) { + if(queryString == null) return null; + String[] keywords = queryString.trim().split("\\s+"); // 공백 여러 칸 이어도 나눔 + + BooleanExpression be = null; + + for(String keyword : keywords) { + if(be == null) { + be = store.name.containsIgnoreCase(keyword); // 대소문자 구분 X + }else{ + be = be.and(store.name.containsIgnoreCase(keyword)); + } + } + + return be; + } + + public static BooleanExpression storeAddressContains(String region) { + return StringUtils.hasText(region) ? store.address.contains(region) : null; + } +} diff --git a/src/main/java/com/example/umc9th/domain/store/repository/StoreQueryDsl.java b/src/main/java/com/example/umc9th/domain/store/repository/StoreQueryDsl.java new file mode 100644 index 0000000..50ff191 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/store/repository/StoreQueryDsl.java @@ -0,0 +1,10 @@ +package com.example.umc9th.domain.store.repository; + +import com.example.umc9th.domain.store.dto.StoreResponseDto; +import com.querydsl.core.types.Predicate; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +public interface StoreQueryDsl { + Slice searchStore(Predicate predicate, Long cursorId, Pageable pageable, String sortType); +} diff --git a/src/main/java/com/example/umc9th/domain/store/repository/StoreRepository.java b/src/main/java/com/example/umc9th/domain/store/repository/StoreRepository.java index e3be7c8..cd125c6 100644 --- a/src/main/java/com/example/umc9th/domain/store/repository/StoreRepository.java +++ b/src/main/java/com/example/umc9th/domain/store/repository/StoreRepository.java @@ -3,5 +3,6 @@ import com.example.umc9th.domain.store.entity.Store; import org.springframework.data.jpa.repository.JpaRepository; -public interface StoreRepository extends JpaRepository { +public interface StoreRepository extends JpaRepository, StoreQueryDsl{ + } diff --git a/src/main/java/com/example/umc9th/domain/store/repository/StoreRepositoryImpl.java b/src/main/java/com/example/umc9th/domain/store/repository/StoreRepositoryImpl.java new file mode 100644 index 0000000..88d1578 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/store/repository/StoreRepositoryImpl.java @@ -0,0 +1,56 @@ +package com.example.umc9th.domain.store.repository; + +import com.example.umc9th.domain.store.dto.StoreResponseDto; +import com.example.umc9th.domain.store.entity.QStore; +import com.querydsl.core.BooleanBuilder; +// (수정) GroupBy import 제거 +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Predicate; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +import java.util.List; + +import static com.example.umc9th.domain.store.entity.QStore.store; + +@Slf4j +@RequiredArgsConstructor +public class StoreRepositoryImpl implements StoreQueryDsl{ + private final JPAQueryFactory queryFactory; + + @Override + public Slice searchStore(Predicate predicate, Long cursorId, Pageable pageable, String sortType) { + BooleanBuilder builder = new BooleanBuilder(predicate); + + if(cursorId != null) { + builder.and(store.id.lt(cursorId)); + } + + int pageSize = pageable.getPageSize(); + + List content = queryFactory + .select(Projections.constructor(StoreResponseDto.SearchedStore.class, + store.id, + store.name, + store.address) + ) + .from(store) + .where(builder) + .orderBy(StoreSort.getOrderList(sortType).toArray(new OrderSpecifier[0])) + .limit(pageSize+1) + .fetch(); + + boolean hasNext = false; + if (content.size() > pageSize) { + content.remove(pageSize); + hasNext = true; + } + + return new SliceImpl<>(content, pageable, hasNext); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/domain/store/repository/StoreSort.java b/src/main/java/com/example/umc9th/domain/store/repository/StoreSort.java new file mode 100644 index 0000000..9fe4b2c --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/store/repository/StoreSort.java @@ -0,0 +1,66 @@ +package com.example.umc9th.domain.store.repository; + +import com.example.umc9th.domain.store.entity.QStore; +import com.querydsl.core.types.OrderSpecifier; +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.NumberExpression; +import com.querydsl.core.types.dsl.StringPath; + +import java.util.ArrayList; +import java.util.List; + +public class StoreSort { + private static final QStore store = QStore.store; + + public static List> getOrderList(String sortType) { + List> orders = new ArrayList<>(); + + switch(sortType.toLowerCase()) { + case "latest": + orders.add(store.createdAt.desc()); + return orders; + case "name": + OrderSpecifier sortPriority = new CaseBuilder() + // [수정] mysqlRegexMatches 헬퍼 메서드 사용 + .when(mysqlRegexMatches(store.name, "^[가-힣]")).then(1) + .when(mysqlRegexMatches(store.name, "^[A-Z]")).then(2) + .when(mysqlRegexMatches(store.name, "^[a-z]")).then(3) + .otherwise(4) + .asc(); + + orders.add(sortPriority); + orders.add(store.name.asc()); + orders.add(store.createdAt.desc()); + + return orders; + default: + return null; + + } + } + + /** + * HQL의 'function()'을 사용해 MySQL의 'REGEXP_LIKE' 함수를 호출합니다. + * MySQL에서 REGEXP_LIKE는 true일 때 1, false일 때 0을 반환합니다. + * + * @param path QStore.store.name + * @param pattern 정규표현식 (예: "^[가-힣]") + * @return BooleanExpression (예: function('REGEXP_LIKE', store.name, '...') = 1) + */ + private static BooleanExpression mysqlRegexMatches(StringPath path, String pattern) { + // 1. HQL 템플릿 생성 + NumberExpression regexpFunction = Expressions.numberTemplate( + Integer.class, + // [핵심 수정] 'REGEXP' -> 'REGEXP_LIKE' + "function('REGEXP_LIKE', {0}, {1})", + path, + pattern + ); + + // 2. HQL 파서가 이해할 수 있는 BooleanExpression 반환 + // (function(...) = 1) + return regexpFunction.eq(1); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/domain/store/service/StoreService.java b/src/main/java/com/example/umc9th/domain/store/service/StoreService.java new file mode 100644 index 0000000..a239f3e --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/store/service/StoreService.java @@ -0,0 +1,12 @@ +package com.example.umc9th.domain.store.service; + +import com.example.umc9th.domain.store.dto.StoreResponseDto; +import com.example.umc9th.global.dto.CursorResponseDto; + +public interface StoreService { + CursorResponseDto searchStore(String storeName, + String region, + Long cursorId, + int size, + String sortType); +} diff --git a/src/main/java/com/example/umc9th/domain/store/service/StoreServiceImpl.java b/src/main/java/com/example/umc9th/domain/store/service/StoreServiceImpl.java new file mode 100644 index 0000000..a4e3020 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/store/service/StoreServiceImpl.java @@ -0,0 +1,49 @@ +package com.example.umc9th.domain.store.service; + +import com.example.umc9th.domain.store.dto.StoreResponseDto; +import com.example.umc9th.domain.store.entity.Store; +import com.example.umc9th.domain.store.repository.StorePredicate; +import com.example.umc9th.domain.store.repository.StoreRepository; +import com.example.umc9th.domain.store.repository.StoreRepositoryImpl; +import com.example.umc9th.global.dto.CursorResponseDto; +import com.querydsl.core.BooleanBuilder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.converters.models.PageableAsQueryParam; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class StoreServiceImpl implements StoreService { + private final StoreRepository storeRepository; + + @Override + public CursorResponseDto searchStore(String storeName, String region, Long cursorId, int size, String sortType) { + BooleanBuilder predicate = new BooleanBuilder(); + predicate.and(StorePredicate.storeNameContains(storeName)); + predicate.and(StorePredicate.storeAddressContains(region)); + + Pageable pageable = PageRequest.of(0,size); + + Slice slice = storeRepository.searchStore(predicate,cursorId,pageable, sortType); + + String nextCursor = null; + if (slice.hasNext()) { + List content = slice.getContent(); + if (!content.isEmpty()) { + nextCursor = content.get(content.size() - 1).getId().toString(); + } + } + + return new CursorResponseDto<>(slice, nextCursor); + } +}