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
This commit is contained in:
joungmin
2026-06-15 13:21:25 +09:00
parent 7fa623d22d
commit 4407f2d67d
5 changed files with 40 additions and 11 deletions

2
.gitignore vendored
View File

@@ -18,3 +18,5 @@ k8s/secrets.yaml
# OS / misc # OS / misc
.DS_Store .DS_Store
backend/cookies.txt backend/cookies.txt
backend-java/cookies.txt
**/cookies.txt

View File

@@ -38,7 +38,7 @@ public class ExtractorService {
%s %s
- price_range: 가격대 (예: 1만원대, 2-3만원) (string | null) - price_range: 가격대 (예: 1만원대, 2-3만원) (string | null)
- foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성) - foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성)
- evaluation: 평가 내용 (string | null) - evaluation: 평가 내용을 100자 이내로 요약 (string | null)
- guests: 함께한 게스트 (string[]) - guests: 함께한 게스트 (string[])
영상 제목: {title} 영상 제목: {title}
@@ -62,6 +62,10 @@ public class ExtractorService {
*/ */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public ExtractionResult extractRestaurants(String title, String transcript, String customPrompt) { public ExtractionResult extractRestaurants(String title, String transcript, String customPrompt) {
// #292 — transcript null/blank 가드 (NPE 방지)
if (transcript == null || transcript.isBlank()) {
return new ExtractionResult(List.of(), "");
}
// Truncate very long transcripts // Truncate very long transcripts
if (transcript.length() > 8000) { if (transcript.length() > 8000) {
transcript = transcript.substring(0, 7000) + "\n...(중략)...\n" + transcript.substring(transcript.length() - 1000); transcript = transcript.substring(0, 7000) + "\n...(중략)...\n" + transcript.substring(transcript.length() - 1000);

View File

@@ -156,7 +156,15 @@ public class GeocodingService {
if (country.isEmpty() && !city.isEmpty()) country = "한국"; if (country.isEmpty() && !city.isEmpty()) country = "한국";
if (country.isEmpty()) return null; if (country.isEmpty()) return null;
return country + "|" + city + "|" + district; // #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) { private Map<String, Object> geocode(String query) {

View File

@@ -87,6 +87,9 @@ public class PipelineService {
String videoDbId = (String) video.get("id"); String videoDbId = (String) video.get("id");
String title = (String) video.get("title"); String title = (String) video.get("title");
// #292 — 외부 가시성을 위해 진입 시 processing 전이 (이미 processing이면 no-op)
updateVideoStatus(videoDbId, "processing", null, null);
var result = extractorService.extractRestaurants(title, transcript, customPrompt); var result = extractorService.extractRestaurants(title, transcript, customPrompt);
if (result.restaurants().isEmpty()) { if (result.restaurants().isEmpty()) {
updateVideoStatus(videoDbId, "done", null, result.rawResponse()); updateVideoStatus(videoDbId, "done", null, result.rawResponse());
@@ -105,18 +108,26 @@ public class PipelineService {
// Build upsert data // Build upsert data
var data = new HashMap<String, Object>(); var data = new HashMap<String, Object>();
data.put("name", name); data.put("name", name);
data.put("address", geo != null ? geo.get("formatted_address") : restData.get("address"));
data.put("region", restData.get("region")); data.put("region", restData.get("region"));
data.put("latitude", geo != null ? geo.get("latitude") : null);
data.put("longitude", geo != null ? geo.get("longitude") : null);
data.put("cuisine_type", restData.get("cuisine_type")); data.put("cuisine_type", restData.get("cuisine_type"));
data.put("price_range", restData.get("price_range")); data.put("price_range", restData.get("price_range"));
data.put("google_place_id", geo != null ? geo.get("google_place_id") : null); // #292 — geocode 실패(geo==null) 시 좌표/주소/place_id 등 기존 값 보존하기 위해
data.put("phone", geo != null ? geo.get("phone") : null); // null을 명시적으로 put하지 않는다. upsert 측에서 누락 컬럼은 그대로 유지.
data.put("website", geo != null ? geo.get("website") : null); if (geo != null) {
data.put("business_status", geo != null ? geo.get("business_status") : null); data.put("address", geo.get("formatted_address"));
data.put("rating", geo != null ? geo.get("rating") : null); data.put("latitude", geo.get("latitude"));
data.put("rating_count", geo != null ? geo.get("rating_count") : null); data.put("longitude", geo.get("longitude"));
data.put("google_place_id", geo.get("google_place_id"));
data.put("phone", geo.get("phone"));
data.put("website", geo.get("website"));
data.put("business_status", geo.get("business_status"));
data.put("rating", geo.get("rating"));
data.put("rating_count", geo.get("rating_count"));
} else {
// geocode 실패한 첫 등록 케이스에서 최소한의 주소(LLM이 추출한 원시값)는 저장
Object rawAddr = restData.get("address");
if (rawAddr != null) data.put("address", rawAddr);
}
String restId = restaurantService.upsert(data); String restId = restaurantService.upsert(data);

View File

@@ -28,6 +28,9 @@ public class VideoService {
VideoDetail detail = mapper.findDetail(id); VideoDetail detail = mapper.findDetail(id);
if (detail == null) return null; if (detail == null) return null;
List<VideoRestaurantLink> restaurants = mapper.findVideoRestaurants(id); List<VideoRestaurantLink> restaurants = mapper.findVideoRestaurants(id);
if (restaurants != null) {
restaurants.forEach(r -> r.setEvaluation(JsonUtil.normalizeEvaluation(r.getEvaluation())));
}
detail.setRestaurants(restaurants != null ? restaurants : List.of()); detail.setRestaurants(restaurants != null ? restaurants : List.of());
return detail; return detail;
} }
@@ -59,6 +62,7 @@ public class VideoService {
mapper.cleanupOrphanRestaurant(restaurantId); mapper.cleanupOrphanRestaurant(restaurantId);
} }
@Transactional
public int saveVideosBatch(String channelId, List<Map<String, Object>> videos) { public int saveVideosBatch(String channelId, List<Map<String, Object>> videos) {
Set<String> existing = new HashSet<>(mapper.getExistingVideoIds(channelId)); Set<String> existing = new HashSet<>(mapper.getExistingVideoIds(channelId));
int saved = 0; int saved = 0;