diff --git a/src/main/java/com/zerozero/store/infrastructure/kakao/search/application/KakaoSearchService.java b/src/main/java/com/zerozero/store/infrastructure/kakao/search/application/KakaoSearchService.java new file mode 100644 index 0000000..1ad736a --- /dev/null +++ b/src/main/java/com/zerozero/store/infrastructure/kakao/search/application/KakaoSearchService.java @@ -0,0 +1,48 @@ +package com.zerozero.store.infrastructure.kakao.search.application; + +import com.zerozero.store.exception.StoreErrorType; +import com.zerozero.store.exception.StoreException; +import com.zerozero.store.infrastructure.kakao.search.core.KakaoProperty; +import com.zerozero.store.infrastructure.kakao.search.request.KakaoSearchRequest; +import com.zerozero.store.infrastructure.kakao.search.response.KakaoSearchResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class KakaoSearchService { + + private final KakaoProperty kakaoProperty; + private static final String KAKAO_AUTHORIZATION_PREFIX = "KakaoAK "; + + public KakaoSearchResponse search(KakaoSearchRequest kakaoSearchRequest) { + URI uri = UriComponentsBuilder.fromUriString(kakaoProperty.getKeywordUrl()) + .queryParams(kakaoSearchRequest.createQueryParams()) + .build() + .encode() + .toUri(); + + try { + return RestClient.create() + .get() + .uri(uri) + .headers(header -> { + header.set("Authorization", KAKAO_AUTHORIZATION_PREFIX + kakaoProperty.getRestApiKey()); + header.setContentType(MediaType.APPLICATION_JSON); + }) + .retrieve() + .body(KakaoSearchResponse.class); + } catch (Exception e) { + log.error("[KakaoSearchService] error", e); + throw new StoreException(StoreErrorType.KAKAO_SERVICE_UNAVAILABLE); + } + } +} + diff --git a/src/main/java/com/zerozero/store/infrastructure/kakao/search/application/KakaoStoreSearcher.java b/src/main/java/com/zerozero/store/infrastructure/kakao/search/application/KakaoStoreSearcher.java new file mode 100644 index 0000000..f41ac46 --- /dev/null +++ b/src/main/java/com/zerozero/store/infrastructure/kakao/search/application/KakaoStoreSearcher.java @@ -0,0 +1,60 @@ +package com.zerozero.store.infrastructure.kakao.search.application; + +import com.zerozero.store.domain.response.StoreSearchResponse; +import com.zerozero.store.domain.service.StoreSearcher; +import com.zerozero.store.exception.StoreErrorType; +import com.zerozero.store.exception.StoreException; +import com.zerozero.store.infrastructure.kakao.search.request.KakaoSearchRequest; +import com.zerozero.store.infrastructure.kakao.search.response.KakaoSearchResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class KakaoStoreSearcher implements StoreSearcher { + + private final KakaoSearchService kakaoSearchService; + private static final Integer DEFAULT_RADIUS = 2000; + + public List search(String query) { + KakaoSearchResponse kakaoSearchResponse = kakaoSearchService.search( + KakaoSearchRequest.builder() + .query(query) + .build()); + + if (kakaoSearchResponse == null || kakaoSearchResponse.getMeta().getTotalCount() == 0) { + log.error("[KakaoStoreSearcher] Search response is null or empty"); + throw new StoreException(StoreErrorType.NOT_EXIST_SEARCH_RESPONSE); + } + + return Arrays.stream(kakaoSearchResponse.getDocuments()) + .map(StoreSearchResponse::from) + .collect(Collectors.toList()); + } + + public List searchByLocation(String query, double longitude, double latitude) { + KakaoSearchResponse kakaoSearchResponse = kakaoSearchService.search( + KakaoSearchRequest.builder() + .query(query) + .longitude(String.valueOf(longitude)) + .latitude(String.valueOf(latitude)) + .radius(DEFAULT_RADIUS) + .build()); + + if (kakaoSearchResponse == null || kakaoSearchResponse.getMeta().getTotalCount() == 0) { + log.error("[KakaoStoreSearcher] Location-based search response is null or empty"); + throw new StoreException(StoreErrorType.NOT_EXIST_SEARCH_RESPONSE); + } + + return Arrays.stream(kakaoSearchResponse.getDocuments()) + .map(StoreSearchResponse::from) + .collect(Collectors.toList()); + } + +} diff --git a/src/main/java/com/zerozero/store/infrastructure/kakao/search/core/CategoryGroupCode.java b/src/main/java/com/zerozero/store/infrastructure/kakao/search/core/CategoryGroupCode.java new file mode 100644 index 0000000..e20b041 --- /dev/null +++ b/src/main/java/com/zerozero/store/infrastructure/kakao/search/core/CategoryGroupCode.java @@ -0,0 +1,15 @@ +package com.zerozero.store.infrastructure.kakao.search.core; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CategoryGroupCode { + + MT1("대형마트"), CS2("편의점"), PS3("어린이집, 유치원"), SC4("학교"), AC5("학원"), PK6("주차장"), OL7("주유소, 충전소"), + SW8("지하철역"), BK9("은행"), CT1("문화시설"), AG2("중개업소"), PO3("공공기관"), AT4("관광명소"), AD5("숙박"), FD6("음식점"), CE7("카페"), HP8("병원"), PM9( + "약국"); + + private final String description; +} diff --git a/src/main/java/com/zerozero/store/infrastructure/kakao/search/core/KakaoProperty.java b/src/main/java/com/zerozero/store/infrastructure/kakao/search/core/KakaoProperty.java new file mode 100644 index 0000000..80bed9d --- /dev/null +++ b/src/main/java/com/zerozero/store/infrastructure/kakao/search/core/KakaoProperty.java @@ -0,0 +1,17 @@ +package com.zerozero.store.infrastructure.kakao.search.core; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties("kakao") +@Getter +@Setter +public class KakaoProperty { + + private String restApiKey; + + private String keywordUrl; +} diff --git a/src/main/java/com/zerozero/store/infrastructure/kakao/search/request/KakaoSearchRequest.java b/src/main/java/com/zerozero/store/infrastructure/kakao/search/request/KakaoSearchRequest.java new file mode 100644 index 0000000..cac3d3f --- /dev/null +++ b/src/main/java/com/zerozero/store/infrastructure/kakao/search/request/KakaoSearchRequest.java @@ -0,0 +1,55 @@ +package com.zerozero.store.infrastructure.kakao.search.request; + +import com.zerozero.store.infrastructure.kakao.search.core.CategoryGroupCode; +import lombok.Builder; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +@Builder +public record KakaoSearchRequest( + String query, + CategoryGroupCode categoryGroupCode, + String longitude, + String latitude, + Integer radius, + String rect, + Integer page, + Integer size, + String sort +) { + + public MultiValueMap createQueryParams() { + LinkedMultiValueMap queryParams = new LinkedMultiValueMap<>(); + + if (query != null && !query.isEmpty()) { + queryParams.add("query", query); + } + if (categoryGroupCode != null) { + queryParams.add("category_group_code", categoryGroupCode.name()); + } + if (longitude != null && !longitude.isEmpty()) { + queryParams.add("x", longitude); + } + if (latitude != null && !latitude.isEmpty()) { + queryParams.add("y", latitude); + } + if (radius != null) { + queryParams.add("radius", String.valueOf(radius)); + } + if (rect != null && !rect.isEmpty()) { + queryParams.add("rect", rect); + } + if (page != null) { + queryParams.add("page", String.valueOf(page)); + } + if (size != null) { + queryParams.add("size", String.valueOf(size)); + } + if (sort != null && !sort.isEmpty()) { + queryParams.add("sort", sort); + } + + return queryParams; + } +} + diff --git a/src/main/java/com/zerozero/store/infrastructure/kakao/search/response/KakaoSearchResponse.java b/src/main/java/com/zerozero/store/infrastructure/kakao/search/response/KakaoSearchResponse.java new file mode 100644 index 0000000..490462a --- /dev/null +++ b/src/main/java/com/zerozero/store/infrastructure/kakao/search/response/KakaoSearchResponse.java @@ -0,0 +1,85 @@ +package com.zerozero.store.infrastructure.kakao.search.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +public class KakaoSearchResponse { + + private Meta meta; + + private Document[] documents; + + @Getter + @Setter + public static class Meta { + + @JsonProperty("total_count") + private Integer totalCount; + + @JsonProperty("pageable_count") + private Integer pageableCount; + + @JsonProperty("is_end") + private Boolean isEnd; + + @JsonProperty("same_name") + private SameName sameName; + + @Getter + @Setter + public static class SameName { + + private String[] region; + + private String keyword; + + @JsonProperty("selected_region") + private String selectedRegion; + } + } + + @Getter + @Setter + public static class Document { + + private String id; + + @JsonProperty("place_name") + private String placeName; + + @JsonProperty("category_name") + private String categoryName; + + @JsonProperty("category_group_code") + private String categoryGroupCode; + + @JsonProperty("category_group_name") + private String categoryGroupName; + + private String phone; + + @JsonProperty("address_name") + private String addressName; + + @JsonProperty("road_address_name") + private String roadAddressName; + + private String x; + + private String y; + + @JsonProperty("place_url") + private String placeUrl; + + private String distance; + + private boolean status; + + private UUID storeId; + } +} diff --git a/src/main/java/com/zerozero/store/infrastructure/mongodb/Store.java b/src/main/java/com/zerozero/store/infrastructure/mongodb/Store.java new file mode 100644 index 0000000..524d395 --- /dev/null +++ b/src/main/java/com/zerozero/store/infrastructure/mongodb/Store.java @@ -0,0 +1,63 @@ +package com.zerozero.store.infrastructure.mongodb; + +import com.zerozero.core.util.GeoJsonConverter; +import lombok.*; +import org.springframework.data.mongodb.core.geo.GeoJsonPoint; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.UUID; + +@ToString +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "store") +public class Store { + + private UUID storeId; + + private String kakaoId; + + private String name; + + private String category; + + private String phone; + + private String address; + + private String roadAddress; + + private String longitude; + + private String latitude; + + private GeoJsonPoint location; + + private String placeUrl; + + private boolean status; + + private UUID userId; + + public static Store of(com.zerozero.store.domain.model.Store store) { + return Store.builder() + .storeId(store.getId()) + .kakaoId(store.getKakaoId()) + .name(store.getName()) + .category(store.getCategory()) + .phone(store.getPhone()) + .address(store.getAddress().getAddress()) + .roadAddress(store.getAddress().getRoadAddress()) + .longitude(store.getGeoLocation().getLongitude()) + .latitude(store.getGeoLocation().getLatitude()) + .location(GeoJsonConverter.of(store.getGeoLocation().getLongitude(), store.getGeoLocation().getLatitude())) + .placeUrl(store.getPlaceUrl()) + .status(true) + .userId(store.getUserId()) + .build(); + } + +} diff --git a/src/main/java/com/zerozero/store/infrastructure/mongodb/StoreMongoRepository.java b/src/main/java/com/zerozero/store/infrastructure/mongodb/StoreMongoRepository.java new file mode 100644 index 0000000..cdcdde3 --- /dev/null +++ b/src/main/java/com/zerozero/store/infrastructure/mongodb/StoreMongoRepository.java @@ -0,0 +1,13 @@ +package com.zerozero.store.infrastructure.mongodb; + +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Query; + +import java.util.List; +import java.util.UUID; + +public interface StoreMongoRepository extends MongoRepository { + + @Query("{ 'location': { $near: { $geometry: { type: 'Point', coordinates: [?0, ?1] }, $maxDistance: ?2 } } }") + List findStoresWithinCoordinatesRadius(double longitude, double latitude, double maxDistance); +} diff --git a/src/main/java/com/zerozero/store/infrastructure/rabbitmq/CreateStoreMessageConsumer.java b/src/main/java/com/zerozero/store/infrastructure/rabbitmq/CreateStoreMessageConsumer.java new file mode 100644 index 0000000..25b66d6 --- /dev/null +++ b/src/main/java/com/zerozero/store/infrastructure/rabbitmq/CreateStoreMessageConsumer.java @@ -0,0 +1,27 @@ +package com.zerozero.store.infrastructure.rabbitmq; + +import com.zerozero.core.infrastructure.rabbitmq.MessageConsumer; +import com.zerozero.store.domain.service.CreateStoreMongoUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Component +@RequiredArgsConstructor +@Transactional +@Log4j2 +public class CreateStoreMessageConsumer implements MessageConsumer { + + private final CreateStoreMongoUseCase createStoreMongoUseCase; + + @Override + @RabbitListener(queues = "${create-store-queue.queue}") + public void consumeMessage(UUID storeId) { + createStoreMongoUseCase.execute(storeId); + } + +} diff --git a/src/main/java/com/zerozero/store/infrastructure/rabbitmq/CreateStoreMessageProducer.java b/src/main/java/com/zerozero/store/infrastructure/rabbitmq/CreateStoreMessageProducer.java new file mode 100644 index 0000000..ee83117 --- /dev/null +++ b/src/main/java/com/zerozero/store/infrastructure/rabbitmq/CreateStoreMessageProducer.java @@ -0,0 +1,16 @@ +package com.zerozero.store.infrastructure.rabbitmq; + +import com.zerozero.core.infrastructure.rabbitmq.MessageProducer; + +import java.util.UUID; + +public class CreateStoreMessageProducer extends MessageProducer { + + private CreateStoreMessageProducer() { + super(null, null); + } + + public CreateStoreMessageProducer(CreateStoreQueueProperty queueProperty, UUID storeId) { + super(queueProperty, storeId); + } +} diff --git a/src/main/java/com/zerozero/store/infrastructure/rabbitmq/CreateStoreQueueProperty.java b/src/main/java/com/zerozero/store/infrastructure/rabbitmq/CreateStoreQueueProperty.java new file mode 100644 index 0000000..7bffb7c --- /dev/null +++ b/src/main/java/com/zerozero/store/infrastructure/rabbitmq/CreateStoreQueueProperty.java @@ -0,0 +1,10 @@ +package com.zerozero.store.infrastructure.rabbitmq; + +import com.zerozero.core.infrastructure.rabbitmq.BaseQueueProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties("create-store-queue") +public class CreateStoreQueueProperty extends BaseQueueProperty { +} diff --git a/src/main/java/com/zerozero/store/infrastructure/websocket/handler/ReadNearbyStoresWebSocketHandler.java b/src/main/java/com/zerozero/store/infrastructure/websocket/handler/ReadNearbyStoresWebSocketHandler.java new file mode 100644 index 0000000..7119273 --- /dev/null +++ b/src/main/java/com/zerozero/store/infrastructure/websocket/handler/ReadNearbyStoresWebSocketHandler.java @@ -0,0 +1,50 @@ +package com.zerozero.store.infrastructure.websocket.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zerozero.store.domain.request.StoreLocationRequest; +import com.zerozero.store.domain.response.StoreResponse; +import com.zerozero.store.domain.service.ReadNearbyStoresUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Controller; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.util.List; + +@Controller +@RequiredArgsConstructor +@Log4j2 +public class ReadNearbyStoresWebSocketHandler extends TextWebSocketHandler { + + private final ObjectMapper objectMapper; + + private final ReadNearbyStoresUseCase readNearbyStoresUseCase; + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { + String payload = message.getPayload(); + StoreLocationRequest storeLocationRequest = objectMapper.readValue(payload, StoreLocationRequest.class); + List stores = readNearbyStoresUseCase.execute(storeLocationRequest); + if (stores == null) { + log.error("[ReadNearbyStoresWebSocketHandler] stores is null"); + return; + } + session.sendMessage(new TextMessage(objectMapper.writeValueAsString(stores))); + } + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + log.info("[ReadNearbyStoresWebSocketHandler] Connection established: {}", session.getId()); + super.afterConnectionEstablished(session); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { + log.info("[ReadNearbyStoresWebSocketHandler] Connection closed: {} with userStatus {}", session.getId(), status); + super.afterConnectionClosed(session, status); + } + +}