diff --git a/src/main/java/com/jangburich/domain/store/domain/controller/StoreController.java b/src/main/java/com/jangburich/domain/store/domain/controller/StoreController.java index f588397..cb60fb4 100644 --- a/src/main/java/com/jangburich/domain/store/domain/controller/StoreController.java +++ b/src/main/java/com/jangburich/domain/store/domain/controller/StoreController.java @@ -1,12 +1,20 @@ package com.jangburich.domain.store.domain.controller; +import com.jangburich.domain.store.domain.Category; +import com.jangburich.domain.store.domain.dto.condition.StoreSearchCondition; +import com.jangburich.domain.store.domain.dto.condition.StoreSearchConditionWithType; +import com.jangburich.domain.store.domain.dto.response.SearchStoresResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; 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 com.jangburich.domain.oauth.domain.CustomOAuthUser; @@ -27,8 +35,32 @@ @RequiredArgsConstructor @RequestMapping("/store") public class StoreController { + private final StoreService storeService; + @Operation(summary = "카테고리 별 가게 목록 조회", description = "카테고리 별로 가게 목록을 조회합니다.") + @GetMapping + public ResponseCustom> searchByCategory( + Authentication authentication, + @RequestParam(required = false, defaultValue = "3") Integer searchRadius, + @RequestParam(required = false, defaultValue = "ALL") Category category, + @ModelAttribute StoreSearchCondition storeSearchCondition, + Pageable pageable + ) { + return ResponseCustom.OK(storeService.searchByCategory(authentication, searchRadius, category, storeSearchCondition, pageable)); + } + + @Operation(summary = "매장 찾기(검색)", description = "검색어와 매장 유형에 맞는 매장을 검색합니다.") + @GetMapping("/search") + public ResponseCustom> searchStores( + Authentication authentication, + @RequestParam(required = false, defaultValue = "") String keyword, + @ModelAttribute StoreSearchConditionWithType storeSearchConditionWithType, + Pageable pageable + ) { + return ResponseCustom.OK(storeService.searchStores(authentication, keyword, storeSearchConditionWithType, pageable)); + } + @Operation(summary = "가게 등록", description = "신규 파트너 가게를 등록합니다.") @PostMapping("/create") public ResponseCustom createStore(Authentication authentication, @@ -63,7 +95,7 @@ public ResponseCustom updateStore(Authentication authentication, @PathV } @Operation(summary = "가게 정보 조회", description = "가게 상세 정보를 조회합니다.") - @GetMapping("") + @GetMapping("/{storeId}") public ResponseCustom getStoreInfo(Authentication authentication) { CustomOAuthUser customOAuth2User = (CustomOAuthUser)authentication.getPrincipal(); return ResponseCustom.OK(storeService.getStoreInfo(customOAuth2User)); diff --git a/src/main/java/com/jangburich/domain/store/domain/dto/condition/StoreSearchCondition.java b/src/main/java/com/jangburich/domain/store/domain/dto/condition/StoreSearchCondition.java new file mode 100644 index 0000000..96249c4 --- /dev/null +++ b/src/main/java/com/jangburich/domain/store/domain/dto/condition/StoreSearchCondition.java @@ -0,0 +1,7 @@ +package com.jangburich.domain.store.domain.dto.condition; + +public record StoreSearchCondition( + Double lat, + Double lon +) { +} \ No newline at end of file diff --git a/src/main/java/com/jangburich/domain/store/domain/dto/condition/StoreSearchConditionWithType.java b/src/main/java/com/jangburich/domain/store/domain/dto/condition/StoreSearchConditionWithType.java new file mode 100644 index 0000000..d807d6c --- /dev/null +++ b/src/main/java/com/jangburich/domain/store/domain/dto/condition/StoreSearchConditionWithType.java @@ -0,0 +1,20 @@ +package com.jangburich.domain.store.domain.dto.condition; + +import com.querydsl.core.annotations.QueryProjection; + +public record StoreSearchConditionWithType( + Boolean isReservable, + Boolean isOperational, + Boolean isPrepaid, + Boolean isPostpaid +) { + + @QueryProjection + public StoreSearchConditionWithType(Boolean isReservable, Boolean isOperational, Boolean isPrepaid, + Boolean isPostpaid) { + this.isReservable = isReservable; + this.isOperational = isOperational; + this.isPrepaid = isPrepaid; + this.isPostpaid = isPostpaid; + } +} \ No newline at end of file diff --git a/src/main/java/com/jangburich/domain/store/domain/dto/response/SearchStoresResponse.java b/src/main/java/com/jangburich/domain/store/domain/dto/response/SearchStoresResponse.java new file mode 100644 index 0000000..3036c13 --- /dev/null +++ b/src/main/java/com/jangburich/domain/store/domain/dto/response/SearchStoresResponse.java @@ -0,0 +1,33 @@ +package com.jangburich.domain.store.domain.dto.response; + +import com.jangburich.domain.store.domain.Category; +import com.querydsl.core.annotations.QueryProjection; +import lombok.Builder; + +@Builder +public record SearchStoresResponse( + Long storeId, + String name, + Boolean isFavorite, + Category category, + Double distance, + String businessStatus, + String closeTime, + String phoneNumber, + String imageUrl +) { + + @QueryProjection + public SearchStoresResponse(Long storeId, String name, Boolean isFavorite, Category category, Double distance, + String businessStatus, String closeTime, String phoneNumber, String imageUrl) { + this.storeId = storeId; + this.name = name; + this.isFavorite = isFavorite; + this.category = category; + this.distance = distance; + this.businessStatus = businessStatus; + this.closeTime = closeTime; + this.phoneNumber = phoneNumber; + this.imageUrl = imageUrl; + } +} \ No newline at end of file diff --git a/src/main/java/com/jangburich/domain/store/domain/repository/StoreQueryDslRepository.java b/src/main/java/com/jangburich/domain/store/domain/repository/StoreQueryDslRepository.java new file mode 100644 index 0000000..d2e618d --- /dev/null +++ b/src/main/java/com/jangburich/domain/store/domain/repository/StoreQueryDslRepository.java @@ -0,0 +1,14 @@ +package com.jangburich.domain.store.domain.repository; + +import com.jangburich.domain.store.domain.Category; +import com.jangburich.domain.store.domain.dto.condition.StoreSearchCondition; +import com.jangburich.domain.store.domain.dto.condition.StoreSearchConditionWithType; +import com.jangburich.domain.store.domain.dto.response.SearchStoresResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface StoreQueryDslRepository { + Page findStoresByCategory(Long userId, Integer searchRadius, Category category, StoreSearchCondition storeSearchCondition, Pageable pageable); + + Page findStores(Long userId, String keyword, StoreSearchConditionWithType storeSearchConditionWithType, Pageable pageable); +} diff --git a/src/main/java/com/jangburich/domain/store/domain/repository/StoreQueryDslRepositoryImpl.java b/src/main/java/com/jangburich/domain/store/domain/repository/StoreQueryDslRepositoryImpl.java new file mode 100644 index 0000000..30ef5c1 --- /dev/null +++ b/src/main/java/com/jangburich/domain/store/domain/repository/StoreQueryDslRepositoryImpl.java @@ -0,0 +1,117 @@ +package com.jangburich.domain.store.domain.repository; + +import static com.jangburich.domain.store.domain.QStore.store; + +import com.jangburich.domain.store.domain.Category; +import com.jangburich.domain.store.domain.dto.condition.StoreSearchCondition; +import com.jangburich.domain.store.domain.dto.condition.StoreSearchConditionWithType; +import com.jangburich.domain.store.domain.dto.response.QSearchStoresResponse; +import com.jangburich.domain.store.domain.dto.response.SearchStoresResponse; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.stereotype.Repository; + +@RequiredArgsConstructor +@Repository +public class StoreQueryDslRepositoryImpl implements StoreQueryDslRepository { + + private static final int RADIUS_OF_EARTH_KM = 6371; + + private final JPAQueryFactory queryFactory; + + @Override + public Page findStoresByCategory(Long userId, Integer searchRadius, Category category, + StoreSearchCondition storeSearchCondition, Pageable pageable) { + double myCurrentLat = storeSearchCondition.lat(); + double myCurrentLon = storeSearchCondition.lon(); + + List results = queryFactory + .select(new QSearchStoresResponse(store.id, store.name, Expressions.FALSE, store.category, + Expressions.constant(1.0), Expressions.constant("open"), + store.closeTime.stringValue(), store.contactNumber, store.representativeImage)) + .from(store) + .where( + store.category.eq(category), + withinSearchRadius(myCurrentLat, myCurrentLon, searchRadius, store.latitude, store.longitude) + ) + .orderBy(store.id.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(store.count()) + .from(store) + .where( + store.category.eq(category), + withinSearchRadius(myCurrentLat, myCurrentLon, searchRadius, store.latitude, store.longitude) + ); + + return PageableExecutionUtils.getPage(results, pageable, () -> countQuery.fetch().size()); + } + + @Override + public Page findStores(Long userId, String keyword, + StoreSearchConditionWithType storeSearchConditionWithType, + Pageable pageable) { + Boolean reservable = storeSearchConditionWithType.isReservable(); + Boolean operational = storeSearchConditionWithType.isOperational(); + Boolean prepaid = storeSearchConditionWithType.isPrepaid(); + Boolean postpaid = storeSearchConditionWithType.isPostpaid(); + + List results = queryFactory + .select(new QSearchStoresResponse(store.id, store.name, Expressions.FALSE, store.category, + Expressions.constant(1.0), Expressions.constant("open"), + store.closeTime.stringValue(), store.contactNumber, store.representativeImage)) + .from(store) + .where( + store.reservationAvailable.eq(reservable), + store.name.contains(keyword) + ) + .orderBy(store.id.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(store.count()) + .from(store) + .where( + store.reservationAvailable.eq(reservable), + store.name.contains(keyword) + ); + + return PageableExecutionUtils.getPage(results, pageable, () -> countQuery.fetch().size()); + } + + // TO DO + private BooleanExpression isStoreCurrentlyOpen(String openTime, String closeTime) { + DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("HH:mm"); + + String currentTimeStr = LocalTime.now().format(timeFormatter); + LocalTime currentTime = LocalTime.parse(currentTimeStr, timeFormatter); + + LocalTime open = LocalTime.parse(openTime, timeFormatter); + LocalTime close = LocalTime.parse(closeTime, timeFormatter); + + return Expressions.booleanTemplate("{0} >= {1} and {0} < {2}", currentTime, openTime, closeTime); + } + + private BooleanExpression withinSearchRadius(double userLat, double userLng, int searchRadius, + com.querydsl.core.types.dsl.NumberPath storeLat, + com.querydsl.core.types.dsl.NumberPath storeLng) { + return Expressions.numberTemplate(Double.class, + "({0} * acos(cos(radians({1})) * cos(radians({2})) * cos(radians({3}) - radians({4})) + sin(radians({1})) * sin(radians({2}))))", + RADIUS_OF_EARTH_KM, userLat, storeLat, storeLng, userLng) + .loe(searchRadius); + } +} diff --git a/src/main/java/com/jangburich/domain/store/domain/repository/StoreRepository.java b/src/main/java/com/jangburich/domain/store/domain/repository/StoreRepository.java index 85b1bb8..7525f86 100644 --- a/src/main/java/com/jangburich/domain/store/domain/repository/StoreRepository.java +++ b/src/main/java/com/jangburich/domain/store/domain/repository/StoreRepository.java @@ -9,6 +9,6 @@ import com.jangburich.domain.store.domain.Store; @Repository -public interface StoreRepository extends JpaRepository { +public interface StoreRepository extends JpaRepository, StoreQueryDslRepository { Optional findByOwner(Owner owner); } diff --git a/src/main/java/com/jangburich/domain/store/domain/service/StoreService.java b/src/main/java/com/jangburich/domain/store/domain/service/StoreService.java index b185b3b..3e8e1a3 100644 --- a/src/main/java/com/jangburich/domain/store/domain/service/StoreService.java +++ b/src/main/java/com/jangburich/domain/store/domain/service/StoreService.java @@ -1,5 +1,13 @@ package com.jangburich.domain.store.domain.service; +import com.jangburich.domain.store.domain.Category; +import com.jangburich.domain.store.domain.dto.condition.StoreSearchCondition; +import com.jangburich.domain.store.domain.dto.condition.StoreSearchConditionWithType; +import com.jangburich.domain.store.domain.dto.response.SearchStoresResponse; +import com.jangburich.utils.parser.AuthenticationParser; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -133,4 +141,24 @@ public StoreGetResponseDTO getStoreInfo(CustomOAuthUser customOAuth2User) { return new StoreGetResponseDTO().of(store); } + + public Page searchByCategory(final Authentication authentication, + final Integer searchRadius, + final Category category, + final StoreSearchCondition storeSearchCondition, + final Pageable pageable) { + String parsed = AuthenticationParser.parseUserId(authentication); + User user = userRepository.findByProviderId(parsed) + .orElseThrow(() -> new DefaultNullPointerException(ErrorCode.INVALID_AUTHENTICATION)); + return storeRepository.findStoresByCategory(user.getUserId(), searchRadius, category, storeSearchCondition, pageable); + } + + public Page searchStores(final Authentication authentication, final String keyword, + final StoreSearchConditionWithType storeSearchConditionWithType, + final Pageable pageable) { + String parsed = AuthenticationParser.parseUserId(authentication); + User user = userRepository.findByProviderId(parsed) + .orElseThrow(() -> new DefaultNullPointerException(ErrorCode.INVALID_AUTHENTICATION)); + return storeRepository.findStores(user.getUserId(), keyword, storeSearchConditionWithType, pageable); + } } diff --git a/src/main/java/com/jangburich/domain/store/exception/ResourceNotFoundException.java b/src/main/java/com/jangburich/domain/store/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..1098845 --- /dev/null +++ b/src/main/java/com/jangburich/domain/store/exception/ResourceNotFoundException.java @@ -0,0 +1,8 @@ +package com.jangburich.domain.store.exception; + +public class ResourceNotFoundException extends RuntimeException { + + public ResourceNotFoundException(String message) { + super(message); + } +} \ No newline at end of file