#292: - ExtractorService.extractRestaurants: transcript null/blank 가드 (NPE 방지) - PipelineService.processExtract: 진입 시 status='processing' 명시 전이 - PipelineService.processExtract: geocode 실패(geo==null) 시 좌표/place_id/주소 관련 컬럼을 data에 put하지 않아 upsert COALESCE 보존 의도 명확화 - GeocodingService.parseRegionFromAddress: 빈 토큰을 region 문자열에 포함하지 않도록 정규화 (예: '한국||구' 같은 깨진 토큰 방지) #291: - VideoService.saveVideosBatch: @Transactional 추가 → batch insert 원자성 - .gitignore: backend-java/cookies.txt 및 **/cookies.txt 명시 (보안 노출 차단) 후속 분리: #325 (#291 잔여 MINOR), #326 (#292 parseJson 최적화 + MINOR) Refs: #291 #292
202 lines
8.0 KiB
Java
202 lines
8.0 KiB
Java
package com.tasteby.service;
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.web.reactive.function.client.WebClient;
|
|
|
|
import java.time.Duration;
|
|
import java.util.HashMap;
|
|
import java.util.Map;
|
|
|
|
@Service
|
|
public class GeocodingService {
|
|
|
|
private static final Logger log = LoggerFactory.getLogger(GeocodingService.class);
|
|
|
|
private final WebClient webClient;
|
|
private final ObjectMapper mapper;
|
|
private final String apiKey;
|
|
|
|
public GeocodingService(ObjectMapper mapper,
|
|
@Value("${app.google.maps-api-key}") String apiKey) {
|
|
this.webClient = WebClient.builder()
|
|
.baseUrl("https://maps.googleapis.com/maps/api")
|
|
.build();
|
|
this.mapper = mapper;
|
|
this.apiKey = apiKey;
|
|
}
|
|
|
|
/**
|
|
* Look up restaurant coordinates via Google Maps.
|
|
* Tries Places Text Search first, falls back to Geocoding API.
|
|
*/
|
|
public Map<String, Object> geocodeRestaurant(String name, String address) {
|
|
String query = name;
|
|
if (address != null && !address.isBlank()) {
|
|
query += " " + address;
|
|
}
|
|
|
|
// Try Places Text Search
|
|
Map<String, Object> result = placesTextSearch(query);
|
|
if (result != null) return result;
|
|
|
|
// Fallback: Geocoding
|
|
return geocode(query);
|
|
}
|
|
|
|
private Map<String, Object> placesTextSearch(String query) {
|
|
try {
|
|
String response = webClient.get()
|
|
.uri(uriBuilder -> uriBuilder.path("/place/textsearch/json")
|
|
.queryParam("query", query)
|
|
.queryParam("key", apiKey)
|
|
.queryParam("language", "ko")
|
|
.queryParam("type", "restaurant")
|
|
.build())
|
|
.retrieve()
|
|
.bodyToMono(String.class)
|
|
.block(Duration.ofSeconds(10));
|
|
|
|
JsonNode data = mapper.readTree(response);
|
|
if (!"OK".equals(data.path("status").asText()) || !data.path("results").has(0)) {
|
|
return null;
|
|
}
|
|
|
|
JsonNode place = data.path("results").get(0);
|
|
JsonNode loc = place.path("geometry").path("location");
|
|
|
|
var result = new HashMap<String, Object>();
|
|
result.put("latitude", loc.path("lat").asDouble());
|
|
result.put("longitude", loc.path("lng").asDouble());
|
|
result.put("formatted_address", place.path("formatted_address").asText(""));
|
|
result.put("google_place_id", place.path("place_id").asText(""));
|
|
|
|
if (!place.path("business_status").isMissingNode()) {
|
|
result.put("business_status", place.path("business_status").asText());
|
|
}
|
|
if (!place.path("rating").isMissingNode()) {
|
|
result.put("rating", place.path("rating").asDouble());
|
|
}
|
|
if (!place.path("user_ratings_total").isMissingNode()) {
|
|
result.put("rating_count", place.path("user_ratings_total").asInt());
|
|
}
|
|
|
|
// Fetch phone/website from Place Details
|
|
String placeId = place.path("place_id").asText(null);
|
|
if (placeId != null) {
|
|
var details = placeDetails(placeId);
|
|
if (details != null) {
|
|
result.putAll(details);
|
|
}
|
|
}
|
|
return result;
|
|
} catch (Exception e) {
|
|
log.warn("Places text search failed for '{}': {}", query, e.getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private Map<String, Object> placeDetails(String placeId) {
|
|
try {
|
|
String response = webClient.get()
|
|
.uri(uriBuilder -> uriBuilder.path("/place/details/json")
|
|
.queryParam("place_id", placeId)
|
|
.queryParam("key", apiKey)
|
|
.queryParam("language", "ko")
|
|
.queryParam("fields", "formatted_phone_number,website")
|
|
.build())
|
|
.retrieve()
|
|
.bodyToMono(String.class)
|
|
.block(Duration.ofSeconds(10));
|
|
|
|
JsonNode data = mapper.readTree(response);
|
|
if (!"OK".equals(data.path("status").asText())) return null;
|
|
|
|
JsonNode res = data.path("result");
|
|
var details = new HashMap<String, Object>();
|
|
if (!res.path("formatted_phone_number").isMissingNode()) {
|
|
details.put("phone", res.path("formatted_phone_number").asText());
|
|
}
|
|
if (!res.path("website").isMissingNode()) {
|
|
details.put("website", res.path("website").asText());
|
|
}
|
|
return details;
|
|
} catch (Exception e) {
|
|
log.warn("Place details failed for '{}': {}", placeId, e.getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse Korean address into region format "나라|시/도|구/군".
|
|
* Example: "대한민국 서울특별시 강남구 역삼동 123" → "한국|서울|강남구"
|
|
*/
|
|
public static String parseRegionFromAddress(String address) {
|
|
if (address == null || address.isBlank()) return null;
|
|
String[] parts = address.split("\\s+");
|
|
String country = "";
|
|
String city = "";
|
|
String district = "";
|
|
|
|
for (String p : parts) {
|
|
if (p.equals("대한민국") || p.equals("South Korea")) {
|
|
country = "한국";
|
|
} else if (p.endsWith("특별시") || p.endsWith("광역시") || p.endsWith("특별자치시")) {
|
|
city = p.replace("특별시", "").replace("광역시", "").replace("특별자치시", "");
|
|
} else if (p.endsWith("도") && !p.endsWith("동") && p.length() <= 5) {
|
|
city = p;
|
|
} else if (p.endsWith("구") || p.endsWith("군") || (p.endsWith("시") && !city.isEmpty())) {
|
|
if (district.isEmpty()) district = p;
|
|
}
|
|
}
|
|
|
|
if (country.isEmpty() && !city.isEmpty()) country = "한국";
|
|
if (country.isEmpty()) return null;
|
|
// #292 — 빈 토큰은 region 문자열에 포함시키지 않는다(`한국||구` 형식 방지).
|
|
StringBuilder sb = new StringBuilder(country);
|
|
if (!city.isEmpty()) {
|
|
sb.append('|').append(city);
|
|
if (!district.isEmpty()) sb.append('|').append(district);
|
|
} else if (!district.isEmpty()) {
|
|
// city 없이 district만 있는 경우는 정확도 낮으므로 무시
|
|
}
|
|
return sb.toString();
|
|
}
|
|
|
|
private Map<String, Object> geocode(String query) {
|
|
try {
|
|
String response = webClient.get()
|
|
.uri(uriBuilder -> uriBuilder.path("/geocode/json")
|
|
.queryParam("address", query)
|
|
.queryParam("key", apiKey)
|
|
.queryParam("language", "ko")
|
|
.build())
|
|
.retrieve()
|
|
.bodyToMono(String.class)
|
|
.block(Duration.ofSeconds(10));
|
|
|
|
JsonNode data = mapper.readTree(response);
|
|
if (!"OK".equals(data.path("status").asText()) || !data.path("results").has(0)) {
|
|
return null;
|
|
}
|
|
|
|
JsonNode result = data.path("results").get(0);
|
|
JsonNode loc = result.path("geometry").path("location");
|
|
|
|
var map = new HashMap<String, Object>();
|
|
map.put("latitude", loc.path("lat").asDouble());
|
|
map.put("longitude", loc.path("lng").asDouble());
|
|
map.put("formatted_address", result.path("formatted_address").asText(""));
|
|
map.put("google_place_id", "");
|
|
return map;
|
|
} catch (Exception e) {
|
|
log.warn("Geocoding failed for '{}': {}", query, e.getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
}
|