Skip to content

Commit affa34d

Browse files
authored
Merge pull request #15 from BusanHackathon/feature/#3-map-api
Feature/#3 map api
2 parents b40a6cd + 5e8145f commit affa34d

13 files changed

Lines changed: 597 additions & 2 deletions

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ dependencies {
3838
testImplementation("org.springframework.boot:spring-boot-starter-test")
3939
testImplementation("org.springframework.security:spring-security-test")
4040
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
41+
implementation ("org.springframework.boot:spring-boot-starter-webflux")
4142

4243
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
4344
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.busan.config;
2+
3+
4+
import org.springframework.beans.factory.annotation.Value;
5+
import org.springframework.context.annotation.*;
6+
import org.springframework.http.HttpHeaders;
7+
import org.springframework.web.reactive.function.client.WebClient;
8+
9+
@Configuration
10+
public class WebClientConfig {
11+
12+
@Bean
13+
public WebClient kakaoWebClient(@Value("${kakao.rest-api-key}") String kakaoKey) {
14+
return WebClient.builder()
15+
.baseUrl("https://dapi.kakao.com")
16+
.defaultHeader(HttpHeaders.AUTHORIZATION, "KakaoAK " + kakaoKey)
17+
.build();
18+
}
19+
}

src/main/java/com/busan/controller/DiagnosisController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import org.springframework.http.ResponseEntity;
1414
import org.springframework.web.bind.annotation.*;
1515

16-
@Tag(name = "Diagnosis", description = "진단하기(합성 API)")
16+
@Tag(name = "diagnosis-controller", description = "진단하기(합성 API)")
1717
@RestController
1818
@RequiredArgsConstructor
1919
@RequestMapping("/api")
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package com.busan.controller;
2+
3+
import com.busan.dto.common.Response;
4+
import com.busan.dto.place.PlaceSearchResponseDTO;
5+
import com.busan.service.CombinedPlaceSearchService;
6+
import io.swagger.v3.oas.annotations.Operation;
7+
import io.swagger.v3.oas.annotations.Parameter;
8+
import io.swagger.v3.oas.annotations.media.Content;
9+
import io.swagger.v3.oas.annotations.media.ExampleObject;
10+
import io.swagger.v3.oas.annotations.tags.Tag;
11+
import lombok.RequiredArgsConstructor;
12+
import org.springdoc.core.annotations.ParameterObject;
13+
import org.springframework.data.domain.Pageable;
14+
import org.springframework.http.ResponseEntity;
15+
import org.springframework.web.bind.annotation.*;
16+
17+
@Tag(name = "places-controller", description = "매물 검색 API")
18+
@RestController
19+
@RequiredArgsConstructor
20+
@RequestMapping("/api/places")
21+
public class PlaceSearchController {
22+
23+
private final CombinedPlaceSearchService combinedPlaceSearchService;
24+
25+
@Operation(
26+
summary = "매물 검색 (내부 + 외부 합성 지원)",
27+
description = """
28+
- 입력:
29+
- q: 주소/키워드 (예: "센텀동로 45"). q만 주어지면 서버가 카카오 주소검색으로 좌표를 얻습니다.
30+
- lat/lng: 검색 중심 좌표(선택). 있으면 반경 검색에 사용합니다.
31+
- radiusKm: 반경(km), 기본=1.
32+
- includeExternal: true 이면 카카오 결과도 함께 병합하여 반환(내부 결과 우선).
33+
- page/size: 페이징.
34+
35+
- 동작 우선순위:
36+
1) lat/lng/radiusKm → 내부 DB 반경 검색 (+옵션: 외부 병합)
37+
2) q만 있을 때 → 카카오 주소검색으로 lat/lng 확보 후 1)과 동일
38+
3) 어떤 조건도 없으면 내부 DB 최신순 페이지 반환
39+
40+
- 예시 호출:
41+
- /api/places/search?q=센텀&page=0&size=20
42+
- /api/places/search?lat=35.1661&lng=129.1322&radiusKm=1&includeExternal=true&page=0&size=20
43+
"""
44+
,
45+
responses = {
46+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
47+
responseCode = "200",
48+
description = "성공 (내부 결과만)",
49+
content = @Content(examples = @ExampleObject(
50+
name = "internal-only",
51+
value = """
52+
{
53+
"data": {
54+
"content": [
55+
{
56+
"placeId": 101,
57+
"landlordId": 55,
58+
"address": "부산 동래구 ...",
59+
"addressDetail": "101동 1001호",
60+
"postCode": "47890",
61+
"geohash": "u4pruyd...",
62+
"lat": 35.1952,
63+
"lng": 129.0871,
64+
"createdAt": "2025-08-12T13:00:00",
65+
"updatedAt": "2025-08-18T11:30:00",
66+
"externalProvider": null,
67+
"externalPlaceId": null
68+
}
69+
],
70+
"page": 0,
71+
"size": 20,
72+
"totalElements": 42,
73+
"totalPages": 3
74+
},
75+
"status": "SUCCESS",
76+
"serverDateTime": "2025-08-18T20:00:00",
77+
"errorCode": null,
78+
"errorMessage": null
79+
}
80+
""")))
81+
,
82+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
83+
responseCode = "200",
84+
description = "성공 (외부 결과 포함)",
85+
content = @Content(examples = @ExampleObject(
86+
name = "with-external",
87+
value = """
88+
{
89+
"data": {
90+
"content": [
91+
{
92+
"placeId": 101,
93+
"landlordId": 55,
94+
"address": "부산 동래구 ...",
95+
"addressDetail": "101동 1001호",
96+
"postCode": "47890",
97+
"geohash": "u4pruyd...",
98+
"lat": 35.1952,
99+
"lng": 129.0871,
100+
"createdAt": "2025-08-12T13:00:00",
101+
"updatedAt": "2025-08-18T11:30:00",
102+
"externalProvider": null,
103+
"externalPlaceId": null
104+
},
105+
{
106+
"placeId": null,
107+
"landlordId": null,
108+
"address": "부산 해운대구 ...",
109+
"addressDetail": null,
110+
"postCode": null,
111+
"geohash": null,
112+
"lat": 35.1661,
113+
"lng": 129.1322,
114+
"createdAt": null,
115+
"updatedAt": null,
116+
"externalProvider": "KAKAO",
117+
"externalPlaceId": "123456"
118+
}
119+
],
120+
"page": 0,
121+
"size": 20,
122+
"totalElements": 2,
123+
"totalPages": 1
124+
},
125+
"status": "SUCCESS",
126+
"serverDateTime": "2025-08-18T20:00:00",
127+
"errorCode": null,
128+
"errorMessage": null
129+
}
130+
""")))
131+
132+
}
133+
)
134+
@GetMapping("/search")
135+
public ResponseEntity<Response<PlaceSearchResponseDTO>> search(
136+
@Parameter(description = "주소 키워드(부분검색)", example = "센텀")
137+
@RequestParam(required = false) String q,
138+
139+
@Parameter(description = "위도", example = "35.1661")
140+
@RequestParam(required = false) Double lat,
141+
142+
@Parameter(description = "경도", example = "129.1322")
143+
@RequestParam(required = false) Double lng,
144+
145+
@Parameter(description = "반경(km)", example = "1")
146+
@RequestParam(required = false) Double radiusKm,
147+
148+
@Parameter(description = "외부(카카오) 결과 포함 여부", example = "true")
149+
@RequestParam(defaultValue = "false") boolean includeExternal,
150+
151+
@ParameterObject Pageable pageable
152+
) {
153+
var dto = combinedPlaceSearchService.searchForUser(q, lat, lng, radiusKm, includeExternal, pageable);
154+
return ResponseEntity.ok(Response.success(dto));
155+
}
156+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.busan.dto.place;
2+
3+
import lombok.*;
4+
import java.math.BigDecimal;
5+
import java.time.LocalDateTime;
6+
7+
@Getter
8+
@Setter
9+
@NoArgsConstructor
10+
@AllArgsConstructor
11+
public class PlaceSearchItemDTO {
12+
private Long placeId;
13+
private Long landlordId;
14+
private String address;
15+
private String addressDetail; // 있으면 내려줌
16+
private String postCode; // 엔티티에 없으면 null 그대로
17+
private String geohash;
18+
private BigDecimal lat;
19+
private BigDecimal lng;
20+
private LocalDateTime createdAt;
21+
private LocalDateTime updatedAt;
22+
23+
// ↓↓↓ 외부 검색 전용 필드 (우리 DB에 없을 수 있음)
24+
private String externalProvider; // "KAKAO"
25+
private String externalPlaceId; // Kakao place_id (Document.id)
26+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.busan.dto.place;
2+
3+
import lombok.*;
4+
import java.util.List;
5+
6+
@Getter
7+
@Setter
8+
@NoArgsConstructor
9+
@AllArgsConstructor
10+
public class PlaceSearchResponseDTO {
11+
private List<PlaceSearchItemDTO> content;
12+
private int page;
13+
private int size;
14+
private long totalElements;
15+
private int totalPages;
16+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.busan.dto.place.external;
2+
3+
import lombok.*;
4+
import java.util.List;
5+
6+
@Getter
7+
@Setter
8+
@NoArgsConstructor
9+
@AllArgsConstructor
10+
public class KakaoSearchResponse {
11+
private Meta meta;
12+
private List<Document> documents;
13+
14+
@Getter
15+
@Setter
16+
@NoArgsConstructor
17+
@AllArgsConstructor
18+
public static class Meta {
19+
private int total_count;
20+
private int pageable_count;
21+
private boolean is_end;
22+
}
23+
24+
@Getter
25+
@Setter
26+
@NoArgsConstructor
27+
@AllArgsConstructor
28+
public static class Document {
29+
private String id; // place_id (키워드 검색일 때)
30+
private String place_name; // 키워드 검색
31+
private String address_name; // 지번 주소
32+
private String road_address_name; // 도로명
33+
private String x; // 경도 문자열
34+
private String y; // 위도 문자열
35+
}
36+
}

src/main/java/com/busan/repository/PlaceRepository.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,21 @@
22

33
import com.busan.entity.Place;
44
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.data.domain.*;
56

67
import java.util.List;
78
import java.util.Optional;
89

9-
public interface PlaceRepository extends JpaRepository<Place, Long> {
10+
public interface PlaceRepository extends JpaRepository<Place, Long>, PlaceRepositoryCustom {
1011
Optional<Place> findTopByAddressAndAddressDetail(String address, String addressDetail);
1112
List<Place> findTop8ByLandlord_LandlordIdOrderByCreatedAtDesc(Long landlordId);
13+
14+
15+
Page<Place> findByAddressContainingIgnoreCase(String q, Pageable pageable);
16+
17+
Page<Place> findByAddressContainingIgnoreCaseAndLandlord_LandlordId(String q, Long landlordId, Pageable pageable);
18+
19+
Page<Place> findByGeohashStartingWith(String geohashPrefix, Pageable pageable);
20+
21+
Page<Place> findByLandlord_LandlordId(Long landlordId, Pageable pageable);
1222
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.busan.repository;
2+
3+
import com.busan.entity.Place;
4+
import org.springframework.data.domain.*;
5+
6+
public interface PlaceRepositoryCustom {
7+
/** lat/lng 중심 반경(km) 원 검색 (Bounding Box + Haversine 보정) */
8+
Page<Place> searchByCircle(double lat, double lng, double radiusKm, Long landlordId, Pageable pageable);
9+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package com.busan.repository;
2+
3+
import com.busan.entity.Place;
4+
import jakarta.persistence.EntityManager;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.data.domain.*;
7+
import org.springframework.stereotype.Repository;
8+
9+
import java.math.BigDecimal;
10+
import java.util.List;
11+
12+
@Repository
13+
@RequiredArgsConstructor
14+
public class PlaceRepositoryImpl implements PlaceRepositoryCustom {
15+
16+
private final EntityManager em;
17+
18+
@Override
19+
public Page<Place> searchByCircle(double lat, double lng, double radiusKm, Long landlordId, Pageable pageable) {
20+
// 1) 대략적 Bounding Box (위경도 1도 ≈ 111.32km 기준)
21+
double degPerKmLat = 1.0 / 111.32;
22+
double degPerKmLng = 1.0 / (111.32 * Math.cos(Math.toRadians(lat)));
23+
24+
double minLat = lat - radiusKm * degPerKmLat;
25+
double maxLat = lat + radiusKm * degPerKmLat;
26+
double minLng = lng - radiusKm * degPerKmLng;
27+
double maxLng = lng + radiusKm * degPerKmLng;
28+
29+
String base = """
30+
FROM Place p
31+
WHERE p.lat BETWEEN :minLat AND :maxLat
32+
AND p.lng BETWEEN :minLng AND :maxLng
33+
""";
34+
if (landlordId != null) base += " AND p.landlord.landlordId = :landlordId";
35+
36+
// count
37+
var countQ = em.createQuery("SELECT COUNT(p) " + base, Long.class)
38+
.setParameter("minLat", BigDecimal.valueOf(minLat))
39+
.setParameter("maxLat", BigDecimal.valueOf(maxLat))
40+
.setParameter("minLng", BigDecimal.valueOf(minLng))
41+
.setParameter("maxLng", BigDecimal.valueOf(maxLng));
42+
if (landlordId != null) countQ.setParameter("landlordId", landlordId);
43+
long total = countQ.getSingleResult();
44+
45+
// page (createdAt 최신순)
46+
var query = em.createQuery("SELECT p " + base + " ORDER BY p.createdAt DESC", Place.class)
47+
.setParameter("minLat", BigDecimal.valueOf(minLat))
48+
.setParameter("maxLat", BigDecimal.valueOf(maxLat))
49+
.setParameter("minLng", BigDecimal.valueOf(minLng))
50+
.setParameter("maxLng", BigDecimal.valueOf(maxLng))
51+
.setFirstResult((int) pageable.getOffset())
52+
.setMaxResults(pageable.getPageSize());
53+
if (landlordId != null) query.setParameter("landlordId", landlordId);
54+
55+
List<Place> rough = query.getResultList();
56+
57+
// 2) (선택) 하버사인으로 실제 원거리 필터링 — 정확도 필요 시 켜기
58+
// 지금은 성능/단순화를 위해 생략하거나, 필요 시 아래 주석 해제
59+
/*
60+
List<Place> filtered = rough.stream().filter(p -> {
61+
double d = haversine(lat, lng, p.getLat().doubleValue(), p.getLng().doubleValue());
62+
return d <= radiusKm;
63+
}).toList();
64+
return new PageImpl<>(filtered, pageable, total);
65+
*/
66+
67+
return new PageImpl<>(rough, pageable, total);
68+
}
69+
70+
// 정확도가 꼭 필요하면 위 주석을 해제하고 사용
71+
@SuppressWarnings("unused")
72+
private static double haversine(double lat1, double lon1, double lat2, double lon2) {
73+
double R = 6371.0088; // km
74+
double dLat = Math.toRadians(lat2 - lat1);
75+
double dLon = Math.toRadians(lon2 - lon1);
76+
double a = Math.sin(dLat/2)*Math.sin(dLat/2)
77+
+ Math.cos(Math.toRadians(lat1))*Math.cos(Math.toRadians(lat2))
78+
+ Math.sin(dLon/2)*Math.sin(dLon/2);
79+
return 2 * R * Math.asin(Math.sqrt(a));
80+
}
81+
}

0 commit comments

Comments
 (0)