From ed3910b9ef2dd589ea6542ded12928723b7d908b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=96=91=EC=A4=80=EC=8B=9D?= Date: Thu, 21 Aug 2025 20:02:45 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feature:=20=EA=B2=80=EC=83=89=20api=20?= =?UTF-8?q?=EC=A0=9C=EC=9E=91=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + .../com/busan/config/WebClientConfig.java | 19 +++ .../controller/PlaceSearchController.java | 156 ++++++++++++++++++ .../busan/dto/place/PlaceSearchItemDTO.java | 26 +++ .../dto/place/PlaceSearchResponseDTO.java | 16 ++ .../place/external/KakaoSearchResponse.java | 36 ++++ .../com/busan/repository/PlaceRepository.java | 12 +- .../repository/PlaceRepositoryCustom.java | 9 + .../busan/repository/PlaceRepositoryImpl.java | 81 +++++++++ .../service/CombinedPlaceSearchService.java | 132 +++++++++++++++ .../com/busan/service/PlaceSearchService.java | 67 ++++++++ .../service/external/KakaoLocalClient.java | 42 +++++ 12 files changed, 596 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/busan/config/WebClientConfig.java create mode 100644 src/main/java/com/busan/controller/PlaceSearchController.java create mode 100644 src/main/java/com/busan/dto/place/PlaceSearchItemDTO.java create mode 100644 src/main/java/com/busan/dto/place/PlaceSearchResponseDTO.java create mode 100644 src/main/java/com/busan/dto/place/external/KakaoSearchResponse.java create mode 100644 src/main/java/com/busan/repository/PlaceRepositoryCustom.java create mode 100644 src/main/java/com/busan/repository/PlaceRepositoryImpl.java create mode 100644 src/main/java/com/busan/service/CombinedPlaceSearchService.java create mode 100644 src/main/java/com/busan/service/PlaceSearchService.java create mode 100644 src/main/java/com/busan/service/external/KakaoLocalClient.java diff --git a/build.gradle.kts b/build.gradle.kts index 71ae2bb..a81fd8e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.security:spring-security-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + implementation ("org.springframework.boot:spring-boot-starter-webflux") implementation("io.jsonwebtoken:jjwt-api:0.11.5") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5") diff --git a/src/main/java/com/busan/config/WebClientConfig.java b/src/main/java/com/busan/config/WebClientConfig.java new file mode 100644 index 0000000..38806f3 --- /dev/null +++ b/src/main/java/com/busan/config/WebClientConfig.java @@ -0,0 +1,19 @@ +package com.busan.config; + + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.*; +import org.springframework.http.HttpHeaders; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient kakaoWebClient(@Value("${kakao.rest-api-key}") String kakaoKey) { + return WebClient.builder() + .baseUrl("https://dapi.kakao.com") + .defaultHeader(HttpHeaders.AUTHORIZATION, "KakaoAK " + kakaoKey) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/busan/controller/PlaceSearchController.java b/src/main/java/com/busan/controller/PlaceSearchController.java new file mode 100644 index 0000000..ad9423e --- /dev/null +++ b/src/main/java/com/busan/controller/PlaceSearchController.java @@ -0,0 +1,156 @@ +package com.busan.controller; + +import com.busan.dto.common.Response; +import com.busan.dto.place.PlaceSearchResponseDTO; +import com.busan.service.CombinedPlaceSearchService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Places", description = "매물 검색 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/places") +public class PlaceSearchController { + + private final CombinedPlaceSearchService combinedPlaceSearchService; + + @Operation( + summary = "매물 검색 (내부 + 외부 합성 지원)", + description = """ + - 입력: + - q: 주소/키워드 (예: "센텀동로 45"). q만 주어지면 서버가 카카오 주소검색으로 좌표를 얻습니다. + - lat/lng: 검색 중심 좌표(선택). 있으면 반경 검색에 사용합니다. + - radiusKm: 반경(km), 기본=1. + - includeExternal: true 이면 카카오 결과도 함께 병합하여 반환(내부 결과 우선). + - page/size: 페이징. + + - 동작 우선순위: + 1) lat/lng/radiusKm → 내부 DB 반경 검색 (+옵션: 외부 병합) + 2) q만 있을 때 → 카카오 주소검색으로 lat/lng 확보 후 1)과 동일 + 3) 어떤 조건도 없으면 내부 DB 최신순 페이지 반환 + + - 예시 호출: + - /api/places/search?q=센텀&page=0&size=20 + - /api/places/search?lat=35.1661&lng=129.1322&radiusKm=1&includeExternal=true&page=0&size=20 + """ + , + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공 (내부 결과만)", + content = @Content(examples = @ExampleObject( + name = "internal-only", + value = """ + { + "data": { + "content": [ + { + "placeId": 101, + "landlordId": 55, + "address": "부산 동래구 ...", + "addressDetail": "101동 1001호", + "postCode": "47890", + "geohash": "u4pruyd...", + "lat": 35.1952, + "lng": 129.0871, + "createdAt": "2025-08-12T13:00:00", + "updatedAt": "2025-08-18T11:30:00", + "externalProvider": null, + "externalPlaceId": null + } + ], + "page": 0, + "size": 20, + "totalElements": 42, + "totalPages": 3 + }, + "status": "SUCCESS", + "serverDateTime": "2025-08-18T20:00:00", + "errorCode": null, + "errorMessage": null + } + """))) + , + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공 (외부 결과 포함)", + content = @Content(examples = @ExampleObject( + name = "with-external", + value = """ + { + "data": { + "content": [ + { + "placeId": 101, + "landlordId": 55, + "address": "부산 동래구 ...", + "addressDetail": "101동 1001호", + "postCode": "47890", + "geohash": "u4pruyd...", + "lat": 35.1952, + "lng": 129.0871, + "createdAt": "2025-08-12T13:00:00", + "updatedAt": "2025-08-18T11:30:00", + "externalProvider": null, + "externalPlaceId": null + }, + { + "placeId": null, + "landlordId": null, + "address": "부산 해운대구 ...", + "addressDetail": null, + "postCode": null, + "geohash": null, + "lat": 35.1661, + "lng": 129.1322, + "createdAt": null, + "updatedAt": null, + "externalProvider": "KAKAO", + "externalPlaceId": "123456" + } + ], + "page": 0, + "size": 20, + "totalElements": 2, + "totalPages": 1 + }, + "status": "SUCCESS", + "serverDateTime": "2025-08-18T20:00:00", + "errorCode": null, + "errorMessage": null + } + """))) + + } + ) + @GetMapping("/search") + public ResponseEntity> search( + @Parameter(description = "주소 키워드(부분검색)", example = "센텀") + @RequestParam(required = false) String q, + + @Parameter(description = "위도", example = "35.1661") + @RequestParam(required = false) Double lat, + + @Parameter(description = "경도", example = "129.1322") + @RequestParam(required = false) Double lng, + + @Parameter(description = "반경(km)", example = "1") + @RequestParam(required = false) Double radiusKm, + + @Parameter(description = "외부(카카오) 결과 포함 여부", example = "true") + @RequestParam(defaultValue = "false") boolean includeExternal, + + @ParameterObject Pageable pageable + ) { + var dto = combinedPlaceSearchService.searchForUser(q, lat, lng, radiusKm, includeExternal, pageable); + return ResponseEntity.ok(Response.success(dto)); + } +} diff --git a/src/main/java/com/busan/dto/place/PlaceSearchItemDTO.java b/src/main/java/com/busan/dto/place/PlaceSearchItemDTO.java new file mode 100644 index 0000000..448c22b --- /dev/null +++ b/src/main/java/com/busan/dto/place/PlaceSearchItemDTO.java @@ -0,0 +1,26 @@ +package com.busan.dto.place; + +import lombok.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class PlaceSearchItemDTO { + private Long placeId; + private Long landlordId; + private String address; + private String addressDetail; // 있으면 내려줌 + private String postCode; // 엔티티에 없으면 null 그대로 + private String geohash; + private BigDecimal lat; + private BigDecimal lng; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + // ↓↓↓ 외부 검색 전용 필드 (우리 DB에 없을 수 있음) + private String externalProvider; // "KAKAO" + private String externalPlaceId; // Kakao place_id (Document.id) +} diff --git a/src/main/java/com/busan/dto/place/PlaceSearchResponseDTO.java b/src/main/java/com/busan/dto/place/PlaceSearchResponseDTO.java new file mode 100644 index 0000000..9257e8a --- /dev/null +++ b/src/main/java/com/busan/dto/place/PlaceSearchResponseDTO.java @@ -0,0 +1,16 @@ +package com.busan.dto.place; + +import lombok.*; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class PlaceSearchResponseDTO { + private List content; + private int page; + private int size; + private long totalElements; + private int totalPages; +} \ No newline at end of file diff --git a/src/main/java/com/busan/dto/place/external/KakaoSearchResponse.java b/src/main/java/com/busan/dto/place/external/KakaoSearchResponse.java new file mode 100644 index 0000000..aa89fcd --- /dev/null +++ b/src/main/java/com/busan/dto/place/external/KakaoSearchResponse.java @@ -0,0 +1,36 @@ +package com.busan.dto.place.external; + +import lombok.*; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class KakaoSearchResponse { + private Meta meta; + private List documents; + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class Meta { + private int total_count; + private int pageable_count; + private boolean is_end; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class Document { + private String id; // place_id (키워드 검색일 때) + private String place_name; // 키워드 검색 + private String address_name; // 지번 주소 + private String road_address_name; // 도로명 + private String x; // 경도 문자열 + private String y; // 위도 문자열 + } +} diff --git a/src/main/java/com/busan/repository/PlaceRepository.java b/src/main/java/com/busan/repository/PlaceRepository.java index fb1be53..fb76a96 100644 --- a/src/main/java/com/busan/repository/PlaceRepository.java +++ b/src/main/java/com/busan/repository/PlaceRepository.java @@ -2,11 +2,21 @@ import com.busan.entity.Place; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.domain.*; import java.util.List; import java.util.Optional; -public interface PlaceRepository extends JpaRepository { +public interface PlaceRepository extends JpaRepository, PlaceRepositoryCustom { Optional findTopByAddressAndAddressDetail(String address, String addressDetail); List findTop8ByLandlord_LandlordIdOrderByCreatedAtDesc(Long landlordId); + + + Page findByAddressContainingIgnoreCase(String q, Pageable pageable); + + Page findByAddressContainingIgnoreCaseAndLandlord_LandlordId(String q, Long landlordId, Pageable pageable); + + Page findByGeohashStartingWith(String geohashPrefix, Pageable pageable); + + Page findByLandlord_LandlordId(Long landlordId, Pageable pageable); } diff --git a/src/main/java/com/busan/repository/PlaceRepositoryCustom.java b/src/main/java/com/busan/repository/PlaceRepositoryCustom.java new file mode 100644 index 0000000..759cbd9 --- /dev/null +++ b/src/main/java/com/busan/repository/PlaceRepositoryCustom.java @@ -0,0 +1,9 @@ +package com.busan.repository; + +import com.busan.entity.Place; +import org.springframework.data.domain.*; + +public interface PlaceRepositoryCustom { + /** lat/lng 중심 반경(km) 원 검색 (Bounding Box + Haversine 보정) */ + Page searchByCircle(double lat, double lng, double radiusKm, Long landlordId, Pageable pageable); +} diff --git a/src/main/java/com/busan/repository/PlaceRepositoryImpl.java b/src/main/java/com/busan/repository/PlaceRepositoryImpl.java new file mode 100644 index 0000000..0e0929b --- /dev/null +++ b/src/main/java/com/busan/repository/PlaceRepositoryImpl.java @@ -0,0 +1,81 @@ +package com.busan.repository; + +import com.busan.entity.Place; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.*; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class PlaceRepositoryImpl implements PlaceRepositoryCustom { + + private final EntityManager em; + + @Override + public Page searchByCircle(double lat, double lng, double radiusKm, Long landlordId, Pageable pageable) { + // 1) 대략적 Bounding Box (위경도 1도 ≈ 111.32km 기준) + double degPerKmLat = 1.0 / 111.32; + double degPerKmLng = 1.0 / (111.32 * Math.cos(Math.toRadians(lat))); + + double minLat = lat - radiusKm * degPerKmLat; + double maxLat = lat + radiusKm * degPerKmLat; + double minLng = lng - radiusKm * degPerKmLng; + double maxLng = lng + radiusKm * degPerKmLng; + + String base = """ + FROM Place p + WHERE p.lat BETWEEN :minLat AND :maxLat + AND p.lng BETWEEN :minLng AND :maxLng + """; + if (landlordId != null) base += " AND p.landlord.landlordId = :landlordId"; + + // count + var countQ = em.createQuery("SELECT COUNT(p) " + base, Long.class) + .setParameter("minLat", BigDecimal.valueOf(minLat)) + .setParameter("maxLat", BigDecimal.valueOf(maxLat)) + .setParameter("minLng", BigDecimal.valueOf(minLng)) + .setParameter("maxLng", BigDecimal.valueOf(maxLng)); + if (landlordId != null) countQ.setParameter("landlordId", landlordId); + long total = countQ.getSingleResult(); + + // page (createdAt 최신순) + var query = em.createQuery("SELECT p " + base + " ORDER BY p.createdAt DESC", Place.class) + .setParameter("minLat", BigDecimal.valueOf(minLat)) + .setParameter("maxLat", BigDecimal.valueOf(maxLat)) + .setParameter("minLng", BigDecimal.valueOf(minLng)) + .setParameter("maxLng", BigDecimal.valueOf(maxLng)) + .setFirstResult((int) pageable.getOffset()) + .setMaxResults(pageable.getPageSize()); + if (landlordId != null) query.setParameter("landlordId", landlordId); + + List rough = query.getResultList(); + + // 2) (선택) 하버사인으로 실제 원거리 필터링 — 정확도 필요 시 켜기 + // 지금은 성능/단순화를 위해 생략하거나, 필요 시 아래 주석 해제 + /* + List filtered = rough.stream().filter(p -> { + double d = haversine(lat, lng, p.getLat().doubleValue(), p.getLng().doubleValue()); + return d <= radiusKm; + }).toList(); + return new PageImpl<>(filtered, pageable, total); + */ + + return new PageImpl<>(rough, pageable, total); + } + + // 정확도가 꼭 필요하면 위 주석을 해제하고 사용 + @SuppressWarnings("unused") + private static double haversine(double lat1, double lon1, double lat2, double lon2) { + double R = 6371.0088; // km + double dLat = Math.toRadians(lat2 - lat1); + double dLon = Math.toRadians(lon2 - lon1); + double a = Math.sin(dLat/2)*Math.sin(dLat/2) + + Math.cos(Math.toRadians(lat1))*Math.cos(Math.toRadians(lat2)) + + Math.sin(dLon/2)*Math.sin(dLon/2); + return 2 * R * Math.asin(Math.sqrt(a)); + } +} diff --git a/src/main/java/com/busan/service/CombinedPlaceSearchService.java b/src/main/java/com/busan/service/CombinedPlaceSearchService.java new file mode 100644 index 0000000..c3647f1 --- /dev/null +++ b/src/main/java/com/busan/service/CombinedPlaceSearchService.java @@ -0,0 +1,132 @@ +package com.busan.service; + +import com.busan.dto.place.PlaceSearchItemDTO; +import com.busan.dto.place.PlaceSearchResponseDTO; +import com.busan.dto.place.external.KakaoSearchResponse; +import com.busan.service.external.KakaoLocalClient; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; + +import java.math.BigDecimal; +import java.util.*; + +@Service +@RequiredArgsConstructor +public class CombinedPlaceSearchService { + + private final PlaceSearchService internalSearch; // 내부 검색 서비스 + private final KakaoLocalClient kakaoLocalClient; + + /** + * [사용자용] 내부 + 외부(카카오) 합성 검색 + * - 좌표가 없고 q만 있으면: 카카오 address 지오코딩으로 lat/lng 확보 + * - 내부 결과 + 외부 결과 병합 (위경도 라운드 키로 중복 제거, 내부 우선) + */ + @Transactional(readOnly = true) + public PlaceSearchResponseDTO searchForUser( + String q, Double lat, Double lng, Double radiusKm, + boolean includeExternal, Pageable pageable) { + + // 0) 좌표가 없고 q만 주어졌을 때 → 카카오 address로 좌표 확보 + if ((lat == null || lng == null) && q != null && !q.isBlank()) { + KakaoSearchResponse addr = kakaoLocalClient + .searchAddress(q) + .onErrorResume(e -> Mono.just(new KakaoSearchResponse())) + .block(); + if (addr != null && addr.getDocuments() != null && !addr.getDocuments().isEmpty()) { + var d = addr.getDocuments().get(0); + try { + lng = Double.parseDouble(d.getX()); + lat = Double.parseDouble(d.getY()); + } catch (Exception ignore) { /* 좌표 파싱 실패 시 그냥 q 기반 내부 like 검색으로 진행 */ } + } + } + + // 1) 내부 검색 + var internal = internalSearch.searchForUser(q, lat, lng, radiusKm, pageable); + if (!includeExternal) return internal; + + // 2) 외부 검색 (키워드 기준, 좌표가 있으면 중심 반경) + KakaoSearchResponse ext = null; + if (q != null && !q.isBlank()) { + ext = kakaoLocalClient + .searchKeyword(q, (lng != null ? lng : null), (lat != null ? lat : null), + (radiusKm != null ? (int)Math.round(radiusKm * 1000) : null)) + .block(); + } + + // 3) 내부 + 외부 병합 + var merged = new ArrayList(internal.getContent()); + if (ext != null && ext.getDocuments() != null) { + for (var d : ext.getDocuments()) { + Double elat = safeParse(d.getY()); + Double elng = safeParse(d.getX()); + if (elat == null || elng == null) continue; + + PlaceSearchItemDTO dto = new PlaceSearchItemDTO(); + dto.setPlaceId(null); + dto.setLandlordId(null); + dto.setAddress(firstNonBlank(d.getRoad_address_name(), d.getAddress_name(), d.getPlace_name())); + dto.setAddressDetail(null); + dto.setPostCode(null); + dto.setGeohash(null); // 필요하면 지오해시 라이브러리로 생성 + dto.setLat(BigDecimal.valueOf(elat)); + dto.setLng(BigDecimal.valueOf(elng)); + dto.setCreatedAt(null); + dto.setUpdatedAt(null); + dto.setExternalProvider("KAKAO"); + dto.setExternalPlaceId(d.getId()); + merged.add(dto); + } + } + + // 4) 중복 제거 (위경도 1e-5 라운딩 키) — 내부(placeId != null) 우선 + Map dedup = new LinkedHashMap<>(); + for (var item : merged) { + String key = keyByLatLng(item.getLat(), item.getLng()); + if (dedup.containsKey(key)) { + var exist = dedup.get(key); + if (exist.getPlaceId() == null && item.getPlaceId() != null) dedup.put(key, item); + } else dedup.put(key, item); + } + + // 5) 내부 우선 정렬 + 페이지 슬라이싱(메모리) + var list = new ArrayList<>(dedup.values()); + list.sort(Comparator + .comparing((PlaceSearchItemDTO it) -> it.getPlaceId() == null) // false(내부) 먼저 + .thenComparing(PlaceSearchItemDTO::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder())) + ); + + int from = Math.toIntExact(pageable.getOffset()); + int to = Math.min(from + pageable.getPageSize(), list.size()); + var slice = from >= list.size() ? List.of() : list.subList(from, to); + + return new PlaceSearchResponseDTO( + slice, + pageable.getPageNumber(), + pageable.getPageSize(), + list.size(), + (int) Math.ceil((double) list.size() / pageable.getPageSize()) + ); + } + + /* ========== 내부 유틸 ========== */ + private static Double safeParse(String s) { + try { return (s == null || s.isBlank()) ? null : Double.parseDouble(s); } + catch (Exception e) { return null; } + } + private static String firstNonBlank(String... arr) { + for (var a : arr) if (a != null && !a.isBlank()) return a; + return null; + } + private static String keyByLatLng(BigDecimal lat, BigDecimal lng) { + if (lat == null || lng == null) return UUID.randomUUID().toString(); + double la = Math.round(lat.doubleValue() * 1e5) / 1e5; + double ln = Math.round(lng.doubleValue() * 1e5) / 1e5; + return la + "," + ln; + } +} diff --git a/src/main/java/com/busan/service/PlaceSearchService.java b/src/main/java/com/busan/service/PlaceSearchService.java new file mode 100644 index 0000000..e540766 --- /dev/null +++ b/src/main/java/com/busan/service/PlaceSearchService.java @@ -0,0 +1,67 @@ +package com.busan.service; + +import com.busan.dto.place.PlaceSearchItemDTO; +import com.busan.dto.place.PlaceSearchResponseDTO; +import com.busan.entity.Place; +import com.busan.repository.PlaceRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class PlaceSearchService { + + private final PlaceRepository placeRepository; + + /** + * [사용자용] 내부 DB 검색 (q 또는 lat/lng/radiusKm) + * - landlordId / geohashPrefix 등 내부/관리자용 파라미터는 제거 + */ + @Transactional(readOnly = true) + public PlaceSearchResponseDTO searchForUser( + String q, Double lat, Double lng, Double radiusKm, Pageable pageable) { + + Page page; + + if (lat != null && lng != null && radiusKm != null) { + page = placeRepository.searchByCircle(lat, lng, radiusKm, null, pageable); + } else if (q != null && !q.isBlank()) { + page = placeRepository.findByAddressContainingIgnoreCase(q, pageable); + } else { + // 아무 조건이 없으면 최신순 전체 페이지 + page = placeRepository.findAll(pageable); + } + + List items = page.getContent().stream().map(p -> { + var dto = new PlaceSearchItemDTO(); + dto.setPlaceId(p.getPlaceId()); + dto.setLandlordId(p.getLandlord() != null ? p.getLandlord().getLandlordId() : null); + dto.setAddress(p.getAddress()); + dto.setAddressDetail(p.getAddressDetail()); + // dto.setPostCode(p.getPostCode()); // 컬럼 추가 시 매핑 + dto.setGeohash(p.getGeohash()); + dto.setLat(p.getLat()); + dto.setLng(p.getLng()); + dto.setCreatedAt(p.getCreatedAt()); + dto.setUpdatedAt(p.getUpdatedAt()); + return dto; + }).toList(); + + return new PlaceSearchResponseDTO( + items, + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } + + /* (선택) 기존 메서드가 다른 곳에서 쓰이고 있으면 남겨둬도 됩니다. + public PlaceSearchResponseDTO search(String q, Double lat, Double lng, Double radiusKm, + String geohashPrefix, Long landlordId, Pageable pageable) { ... } + */ +} diff --git a/src/main/java/com/busan/service/external/KakaoLocalClient.java b/src/main/java/com/busan/service/external/KakaoLocalClient.java new file mode 100644 index 0000000..0e7a100 --- /dev/null +++ b/src/main/java/com/busan/service/external/KakaoLocalClient.java @@ -0,0 +1,42 @@ +package com.busan.service.external; + +import com.busan.dto.place.external.KakaoSearchResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Component +@RequiredArgsConstructor +public class KakaoLocalClient { + + private final WebClient kakaoWebClient; + + /** 주소로 좌표 찾기 */ + public Mono searchAddress(String query) { + return kakaoWebClient.get() + .uri(uri -> uri.path("/v2/local/search/address.json").queryParam("query", query).build()) + .retrieve() + .onStatus(s -> !s.is2xxSuccessful(), rsp -> + rsp.bodyToMono(String.class).map(body -> + new IllegalStateException("Kakao address API error " + rsp.statusCode() + " body=" + body))) + .bodyToMono(KakaoSearchResponse.class); + } + + /** 키워드(장소) 검색: x(lng), y(lat), radius(m) 옵션 */ + public Mono searchKeyword(String query, Double x, Double y, Integer radiusM) { + return kakaoWebClient.get() + .uri(uri -> { + var b = uri.path("/v2/local/search/keyword.json").queryParam("query", query); + if (x != null && y != null) { + b.queryParam("x", x).queryParam("y", y).queryParam("radius", radiusM != null ? radiusM : 1000); + } + return b.build(); + }) + .retrieve() + .onStatus(s -> !s.is2xxSuccessful(), rsp -> + rsp.bodyToMono(String.class).map(body -> + new IllegalStateException("Kakao keyword API error " + rsp.statusCode() + " body=" + body))) + .bodyToMono(KakaoSearchResponse.class); + } +} From 5e8145f9714205179ec1265123a7ae659aceab1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=96=91=EC=A4=80=EC=8B=9D?= Date: Thu, 21 Aug 2025 21:52:56 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feature:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/busan/controller/DiagnosisController.java | 2 +- src/main/java/com/busan/controller/PlaceSearchController.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/busan/controller/DiagnosisController.java b/src/main/java/com/busan/controller/DiagnosisController.java index 50cfc9a..3ca0a5b 100644 --- a/src/main/java/com/busan/controller/DiagnosisController.java +++ b/src/main/java/com/busan/controller/DiagnosisController.java @@ -13,7 +13,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -@Tag(name = "Diagnosis", description = "진단하기(합성 API)") +@Tag(name = "diagnosis-controller", description = "진단하기(합성 API)") @RestController @RequiredArgsConstructor @RequestMapping("/api") diff --git a/src/main/java/com/busan/controller/PlaceSearchController.java b/src/main/java/com/busan/controller/PlaceSearchController.java index ad9423e..39873e5 100644 --- a/src/main/java/com/busan/controller/PlaceSearchController.java +++ b/src/main/java/com/busan/controller/PlaceSearchController.java @@ -14,7 +14,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -@Tag(name = "Places", description = "매물 검색 API") +@Tag(name = "places-controller", description = "매물 검색 API") @RestController @RequiredArgsConstructor @RequestMapping("/api/places")