Files
tasteby/backend-java/src/main/java/com/tasteby/service/GeocodingService.java
joungmin 4407f2d67d fix(pipeline): #291+#292 운영 영향 큰 결함 6건 일괄 수정
#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
2026-06-15 13:21:25 +09:00

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;
}
}
}