Files
tasteby/backend-java/src/main/java/com/tasteby/controller/VideoController.java
joungmin 0f985d52a9 벌크 자막/추출 개선, 검색 필터 무시, geocoding 필드 수정, 네이버맵 링크
- 벌크 자막: 브라우저 우선 + API fallback, 광고 즉시 skip, 대기 시간 단축
- 벌크 자막/추출: 선택한 영상만 처리 가능 (체크박스 선택 후 실행)
- 자막 실패 시 no_transcript 상태 마킹하여 재시도 방지
- 검색 시 필터 조건 무시 (채널/장르/가격/지역/영역 초기화)
- 리셋 버튼 클릭 시 검색어 입력란 초기화
- RestaurantMapper updateFields에 google_place_id, rating 등 geocoding 필드 추가
- SearchMapper에 tabling_url, catchtable_url, phone, website 필드 추가
- 식당 상세에 네이버 지도 링크 추가
- YouTubeService.getTranscriptApi public 전환

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:00:40 +09:00

290 lines
13 KiB
Java

package com.tasteby.controller;
import com.tasteby.domain.VideoDetail;
import com.tasteby.domain.VideoSummary;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.*;
import com.tasteby.util.JsonUtil;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/videos")
public class VideoController {
private final VideoService videoService;
private final CacheService cache;
private final YouTubeService youTubeService;
private final PipelineService pipelineService;
private final ExtractorService extractorService;
private final RestaurantService restaurantService;
private final GeocodingService geocodingService;
public VideoController(VideoService videoService, CacheService cache,
YouTubeService youTubeService, PipelineService pipelineService,
ExtractorService extractorService, RestaurantService restaurantService,
GeocodingService geocodingService) {
this.videoService = videoService;
this.cache = cache;
this.youTubeService = youTubeService;
this.pipelineService = pipelineService;
this.extractorService = extractorService;
this.restaurantService = restaurantService;
this.geocodingService = geocodingService;
}
@GetMapping
public List<VideoSummary> list(@RequestParam(required = false) String status) {
return videoService.findAll(status);
}
@GetMapping("/{id}")
public VideoDetail detail(@PathVariable String id) {
var video = videoService.findDetail(id);
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
return video;
}
@PutMapping("/{id}")
public Map<String, Object> updateTitle(@PathVariable String id, @RequestBody Map<String, String> body) {
AuthUtil.requireAdmin();
String title = body.get("title");
if (title == null || title.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "title is required");
}
videoService.updateTitle(id, title);
cache.flush();
return Map.of("ok", true);
}
@PostMapping("/{id}/skip")
public Map<String, Object> skip(@PathVariable String id) {
AuthUtil.requireAdmin();
videoService.updateStatus(id, "skip");
cache.flush();
return Map.of("ok", true);
}
@DeleteMapping("/{id}")
public Map<String, Object> delete(@PathVariable String id) {
AuthUtil.requireAdmin();
videoService.delete(id);
cache.flush();
return Map.of("ok", true);
}
@DeleteMapping("/{videoId}/restaurants/{restaurantId}")
public Map<String, Object> deleteVideoRestaurant(
@PathVariable String videoId, @PathVariable String restaurantId) {
AuthUtil.requireAdmin();
videoService.deleteVideoRestaurant(videoId, restaurantId);
cache.flush();
return Map.of("ok", true);
}
@PostMapping("/{id}/fetch-transcript")
public Map<String, Object> fetchTranscript(@PathVariable String id,
@RequestParam(defaultValue = "auto") String mode) {
AuthUtil.requireAdmin();
var video = videoService.findDetail(id);
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
var result = youTubeService.getTranscript(video.getVideoId(), mode);
if (result == null || result.text() == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No transcript available");
}
videoService.updateTranscript(id, result.text());
return Map.of("ok", true, "length", result.text().length(), "source", result.source());
}
/** 클라이언트(브라우저)에서 가져온 트랜스크립트를 저장 */
@PostMapping("/{id}/upload-transcript")
public Map<String, Object> uploadTranscript(@PathVariable String id,
@RequestBody Map<String, String> body) {
AuthUtil.requireAdmin();
var video = videoService.findDetail(id);
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
String text = body.get("text");
if (text == null || text.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "text is required");
}
videoService.updateTranscript(id, text);
String source = body.getOrDefault("source", "browser");
return Map.of("ok", true, "length", text.length(), "source", source);
}
@GetMapping("/extract/prompt")
public Map<String, Object> getExtractPrompt() {
return Map.of("prompt", extractorService.getPrompt());
}
@PostMapping("/{id}/extract")
public Map<String, Object> extract(@PathVariable String id,
@RequestBody(required = false) Map<String, String> body) {
AuthUtil.requireAdmin();
var video = videoService.findDetail(id);
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
if (video.getTranscriptText() == null || video.getTranscriptText().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No transcript");
}
String customPrompt = body != null ? body.get("prompt") : null;
var videoMap = Map.<String, Object>of("id", id, "video_id", video.getVideoId(), "title", video.getTitle());
int count = pipelineService.processExtract(videoMap, video.getTranscriptText(), customPrompt);
if (count > 0) cache.flush();
return Map.of("ok", true, "restaurants_extracted", count);
}
@GetMapping("/bulk-extract/pending")
public Map<String, Object> bulkExtractPending() {
var videos = videoService.findVideosForBulkExtract();
var summary = videos.stream().map(v -> Map.of("id", v.get("id"), "title", v.get("title"))).toList();
return Map.of("count", videos.size(), "videos", summary);
}
@GetMapping("/bulk-transcript/pending")
public Map<String, Object> bulkTranscriptPending() {
var videos = videoService.findVideosWithoutTranscript();
var summary = videos.stream().map(v -> Map.of("id", v.get("id"), "title", v.get("title"))).toList();
return Map.of("count", videos.size(), "videos", summary);
}
@SuppressWarnings("unchecked")
@PostMapping("/{videoId}/restaurants/manual")
public Map<String, Object> addManualRestaurant(@PathVariable String videoId,
@RequestBody Map<String, Object> body) {
AuthUtil.requireAdmin();
String name = (String) body.get("name");
if (name == null || name.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "name is required");
}
// Geocode
var geo = geocodingService.geocodeRestaurant(name, (String) body.get("address"));
var data = new HashMap<String, Object>();
data.put("name", name);
data.put("address", geo != null ? geo.get("formatted_address") : body.get("address"));
data.put("region", body.get("region"));
data.put("latitude", geo != null ? geo.get("latitude") : null);
data.put("longitude", geo != null ? geo.get("longitude") : null);
data.put("cuisine_type", body.get("cuisine_type"));
data.put("price_range", body.get("price_range"));
data.put("google_place_id", geo != null ? geo.get("google_place_id") : null);
data.put("phone", geo != null ? geo.get("phone") : null);
data.put("website", geo != null ? geo.get("website") : null);
data.put("business_status", geo != null ? geo.get("business_status") : null);
data.put("rating", geo != null ? geo.get("rating") : null);
data.put("rating_count", geo != null ? geo.get("rating_count") : null);
String restId = restaurantService.upsert(data);
// Parse foods and guests
List<String> foods = null;
Object foodsRaw = body.get("foods_mentioned");
if (foodsRaw instanceof List<?>) {
foods = ((List<?>) foodsRaw).stream().map(Object::toString).toList();
} else if (foodsRaw instanceof String s && !s.isBlank()) {
foods = List.of(s.split("\\s*,\\s*"));
}
List<String> guests = null;
Object guestsRaw = body.get("guests");
if (guestsRaw instanceof List<?>) {
guests = ((List<?>) guestsRaw).stream().map(Object::toString).toList();
} else if (guestsRaw instanceof String s && !s.isBlank()) {
guests = List.of(s.split("\\s*,\\s*"));
}
String evaluation = body.get("evaluation") instanceof String s ? s : null;
restaurantService.linkVideoRestaurant(videoId, restId, foods, evaluation, guests);
cache.flush();
return Map.of("ok", true, "restaurant_id", restId);
}
@SuppressWarnings("unchecked")
@PutMapping("/{videoId}/restaurants/{restaurantId}")
public Map<String, Object> updateVideoRestaurant(@PathVariable String videoId,
@PathVariable String restaurantId,
@RequestBody Map<String, Object> body) {
AuthUtil.requireAdmin();
// Update link fields (foods_mentioned, evaluation, guests)
List<String> foods = null;
Object foodsRaw = body.get("foods_mentioned");
if (foodsRaw instanceof List<?>) {
foods = ((List<?>) foodsRaw).stream().map(Object::toString).toList();
} else if (foodsRaw instanceof String s && !s.isBlank()) {
foods = List.of(s.split("\\s*,\\s*"));
}
List<String> guests = null;
Object guestsRaw = body.get("guests");
if (guestsRaw instanceof List<?>) {
guests = ((List<?>) guestsRaw).stream().map(Object::toString).toList();
} else if (guestsRaw instanceof String s && !s.isBlank()) {
guests = List.of(s.split("\\s*,\\s*"));
}
// evaluation must be valid JSON for DB IS JSON constraint
String evaluationJson = null;
Object evalRaw = body.get("evaluation");
if (evalRaw instanceof String s && !s.isBlank()) {
evaluationJson = s.trim().startsWith("{") || s.trim().startsWith("\"") ? s : JsonUtil.toJson(s);
} else if (evalRaw instanceof Map<?, ?>) {
evaluationJson = JsonUtil.toJson(evalRaw);
}
String foodsJson = foods != null ? JsonUtil.toJson(foods) : null;
String guestsJson = guests != null ? JsonUtil.toJson(guests) : null;
videoService.updateVideoRestaurantFields(videoId, restaurantId, foodsJson, evaluationJson, guestsJson);
// Update restaurant fields if provided
var restFields = new HashMap<String, Object>();
for (var key : List.of("name", "address", "region", "cuisine_type", "price_range")) {
if (body.containsKey(key)) restFields.put(key, body.get(key));
}
if (!restFields.isEmpty()) {
// Re-geocode if name or address changed
var existing = restaurantService.findById(restaurantId);
String newName = (String) restFields.get("name");
String newAddr = (String) restFields.get("address");
boolean nameChanged = newName != null && existing != null && !newName.equals(existing.getName());
boolean addrChanged = newAddr != null && existing != null && !newAddr.equals(existing.getAddress());
if (nameChanged || addrChanged) {
String geoName = newName != null ? newName : existing.getName();
String geoAddr = newAddr != null ? newAddr : existing.getAddress();
var geo = geocodingService.geocodeRestaurant(geoName, geoAddr);
if (geo != null) {
restFields.put("latitude", geo.get("latitude"));
restFields.put("longitude", geo.get("longitude"));
restFields.put("google_place_id", geo.get("google_place_id"));
if (geo.containsKey("formatted_address")) {
restFields.put("address", geo.get("formatted_address"));
}
if (geo.containsKey("rating")) restFields.put("rating", geo.get("rating"));
if (geo.containsKey("rating_count")) restFields.put("rating_count", geo.get("rating_count"));
if (geo.containsKey("phone")) restFields.put("phone", geo.get("phone"));
if (geo.containsKey("business_status")) restFields.put("business_status", geo.get("business_status"));
// Parse region from address
String addr = (String) geo.get("formatted_address");
if (addr != null) {
restFields.put("region", GeocodingService.parseRegionFromAddress(addr));
}
}
}
restaurantService.update(restaurantId, restFields);
}
cache.flush();
return Map.of("ok", true);
}
}