- 실제 캐치테이블은 app.catchtable.co.kr/ct/shop/... 형식 - 옛 /shop/, /dining/ 패턴은 contains 매칭 실패 → 첫 회차 1044건 전부 미발견 - 패턴 교정 후 NONE 해제 + 재실행 필요 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
453 lines
20 KiB
Java
453 lines
20 KiB
Java
package com.tasteby.controller;
|
|
|
|
import com.fasterxml.jackson.core.type.TypeReference;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.tasteby.domain.Restaurant;
|
|
import com.tasteby.security.AuthUtil;
|
|
import com.tasteby.dto.RestaurantUpdateDTO;
|
|
import com.tasteby.service.CacheService;
|
|
import com.tasteby.service.GeocodingService;
|
|
import com.tasteby.service.RestaurantService;
|
|
import com.tasteby.service.WebSearchService;
|
|
import jakarta.validation.Valid;
|
|
import jakarta.annotation.PreDestroy;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
import org.springframework.http.HttpStatus;
|
|
import org.springframework.web.bind.annotation.*;
|
|
import org.springframework.web.server.ResponseStatusException;
|
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
|
|
|
import java.util.*;
|
|
import java.util.concurrent.ExecutorService;
|
|
import java.util.concurrent.Executors;
|
|
import java.util.concurrent.ThreadLocalRandom;
|
|
|
|
@RestController
|
|
@RequestMapping("/api/restaurants")
|
|
public class RestaurantController {
|
|
|
|
private static final Logger log = LoggerFactory.getLogger(RestaurantController.class);
|
|
|
|
private final RestaurantService restaurantService;
|
|
private final GeocodingService geocodingService;
|
|
private final CacheService cache;
|
|
private final ObjectMapper objectMapper;
|
|
private final WebSearchService webSearch;
|
|
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
|
|
|
public RestaurantController(RestaurantService restaurantService, GeocodingService geocodingService, CacheService cache, ObjectMapper objectMapper, WebSearchService webSearch) {
|
|
this.restaurantService = restaurantService;
|
|
this.geocodingService = geocodingService;
|
|
this.cache = cache;
|
|
this.objectMapper = objectMapper;
|
|
this.webSearch = webSearch;
|
|
}
|
|
|
|
// #290 — Bean 종료 시 virtual thread executor를 정리하여 리소스 누수 방지.
|
|
@PreDestroy
|
|
public void shutdownExecutor() {
|
|
executor.shutdown();
|
|
}
|
|
|
|
@GetMapping
|
|
public List<Restaurant> list(
|
|
@RequestParam(defaultValue = "100") int limit,
|
|
@RequestParam(defaultValue = "0") int offset,
|
|
@RequestParam(required = false) String cuisine,
|
|
@RequestParam(required = false) String region,
|
|
@RequestParam(required = false) String channel) {
|
|
if (limit > 500) limit = 500;
|
|
String key = cache.makeKey("restaurants", "l=" + limit, "o=" + offset,
|
|
"c=" + cuisine, "r=" + region, "ch=" + channel);
|
|
String cached = cache.getRaw(key);
|
|
if (cached != null) {
|
|
try {
|
|
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
|
|
} catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
|
|
}
|
|
var result = restaurantService.findAll(limit, offset, cuisine, region, channel);
|
|
cache.set(key, result);
|
|
return result;
|
|
}
|
|
|
|
@GetMapping("/{id}")
|
|
public Restaurant get(@PathVariable String id) {
|
|
String key = cache.makeKey("restaurant", id);
|
|
String cached = cache.getRaw(key);
|
|
if (cached != null) {
|
|
try {
|
|
return objectMapper.readValue(cached, Restaurant.class);
|
|
} catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
|
|
}
|
|
var r = restaurantService.findById(id);
|
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
|
cache.set(key, r);
|
|
return r;
|
|
}
|
|
|
|
@PutMapping("/{id}")
|
|
public Map<String, Object> update(@PathVariable String id, @Valid @RequestBody RestaurantUpdateDTO dto) {
|
|
AuthUtil.requireAdmin();
|
|
var r = restaurantService.findById(id);
|
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
|
|
|
// #358 — DTO → Map (null 제외). 화이트리스트는 DTO 필드 자체로 표현.
|
|
Map<String, Object> sanitized = dto.toFieldMap();
|
|
|
|
// Re-geocode if name or address changed
|
|
String newName = (String) sanitized.get("name");
|
|
String newAddress = (String) sanitized.get("address");
|
|
boolean nameChanged = newName != null && !newName.equals(r.getName());
|
|
boolean addressChanged = newAddress != null && !newAddress.equals(r.getAddress());
|
|
if (nameChanged || addressChanged) {
|
|
String geoName = newName != null ? newName : r.getName();
|
|
String geoAddr = newAddress != null ? newAddress : r.getAddress();
|
|
var geo = geocodingService.geocodeRestaurant(geoName, geoAddr);
|
|
if (geo != null) {
|
|
sanitized.put("latitude", geo.get("latitude"));
|
|
sanitized.put("longitude", geo.get("longitude"));
|
|
sanitized.put("google_place_id", geo.get("google_place_id"));
|
|
if (geo.containsKey("formatted_address")) {
|
|
sanitized.put("address", geo.get("formatted_address"));
|
|
}
|
|
if (geo.containsKey("rating")) sanitized.put("rating", geo.get("rating"));
|
|
if (geo.containsKey("rating_count")) sanitized.put("rating_count", geo.get("rating_count"));
|
|
if (geo.containsKey("phone")) sanitized.put("phone", geo.get("phone"));
|
|
if (geo.containsKey("business_status")) sanitized.put("business_status", geo.get("business_status"));
|
|
|
|
String addr = (String) geo.get("formatted_address");
|
|
if (addr != null) {
|
|
sanitized.put("region", GeocodingService.parseRegionFromAddress(addr));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (sanitized.isEmpty()) {
|
|
// 허용 키가 하나도 없으면 no-op
|
|
return Map.of("ok", true, "restaurant", r);
|
|
}
|
|
|
|
restaurantService.update(id, sanitized);
|
|
cache.flush();
|
|
var updated = restaurantService.findById(id);
|
|
return Map.of("ok", true, "restaurant", updated);
|
|
}
|
|
|
|
|
|
@DeleteMapping("/{id}")
|
|
public Map<String, Object> delete(@PathVariable String id) {
|
|
AuthUtil.requireAdmin();
|
|
var r = restaurantService.findById(id);
|
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
|
restaurantService.delete(id);
|
|
cache.flush();
|
|
return Map.of("ok", true);
|
|
}
|
|
|
|
/** 단건 테이블링 URL 검색 */
|
|
@GetMapping("/{id}/tabling-search")
|
|
public List<Map<String, Object>> tablingSearch(@PathVariable String id) {
|
|
AuthUtil.requireAdmin();
|
|
var r = restaurantService.findById(id);
|
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
|
|
|
try {
|
|
return searchTabling(r.getName());
|
|
} catch (Exception e) {
|
|
log.error("[TABLING] Search failed for '{}': {}", r.getName(), e.getMessage());
|
|
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
|
|
}
|
|
}
|
|
|
|
/** 테이블링 미연결 식당 목록 */
|
|
@GetMapping("/tabling-pending")
|
|
public Map<String, Object> tablingPending() {
|
|
AuthUtil.requireAdmin();
|
|
var list = restaurantService.findWithoutTabling();
|
|
var summary = list.stream()
|
|
.map(r -> Map.of("id", (Object) r.getId(), "name", (Object) r.getName()))
|
|
.toList();
|
|
return Map.of("count", list.size(), "restaurants", summary);
|
|
}
|
|
|
|
/** 벌크 테이블링 검색 (SSE) */
|
|
@PostMapping("/bulk-tabling")
|
|
public SseEmitter bulkTabling() {
|
|
AuthUtil.requireAdmin();
|
|
SseEmitter emitter = new SseEmitter(10_800_000L); // 3h — 대량 백필 대응
|
|
|
|
executor.execute(() -> {
|
|
try {
|
|
var restaurants = restaurantService.findWithoutTabling();
|
|
int total = restaurants.size();
|
|
emit(emitter, Map.of("type", "start", "total", total));
|
|
|
|
if (total == 0) {
|
|
emit(emitter, Map.of("type", "complete", "total", 0, "linked", 0, "notFound", 0));
|
|
emitter.complete();
|
|
return;
|
|
}
|
|
|
|
int linked = 0;
|
|
int notFound = 0;
|
|
|
|
for (int i = 0; i < total; i++) {
|
|
var r = restaurants.get(i);
|
|
emit(emitter, Map.of("type", "processing", "current", i + 1,
|
|
"total", total, "name", r.getName()));
|
|
|
|
try {
|
|
var results = searchTabling(r.getName());
|
|
if (!results.isEmpty()) {
|
|
String url = String.valueOf(results.get(0).get("url"));
|
|
String title = String.valueOf(results.get(0).get("title"));
|
|
if (isNameSimilar(r.getName(), title)) {
|
|
restaurantService.update(r.getId(), Map.of("tabling_url", url));
|
|
linked++;
|
|
emit(emitter, Map.of("type", "done", "current", i + 1,
|
|
"name", r.getName(), "url", url, "title", title));
|
|
} else {
|
|
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
|
|
notFound++;
|
|
log.info("[TABLING] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
|
|
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
|
"name", r.getName(), "reason", "이름 불일치: " + title));
|
|
}
|
|
} else {
|
|
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
|
|
notFound++;
|
|
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
|
"name", r.getName()));
|
|
}
|
|
} catch (Exception e) {
|
|
notFound++;
|
|
emit(emitter, Map.of("type", "error", "current", i + 1,
|
|
"name", r.getName(), "message", e.getMessage()));
|
|
}
|
|
|
|
// 랜덤 딜레이 (2~5초)
|
|
int delay = ThreadLocalRandom.current().nextInt(2000, 5001);
|
|
log.info("[TABLING] Waiting {}ms before next search...", delay);
|
|
Thread.sleep(delay);
|
|
}
|
|
|
|
cache.flush();
|
|
emit(emitter, Map.of("type", "complete", "total", total, "linked", linked, "notFound", notFound));
|
|
emitter.complete();
|
|
} catch (Exception e) {
|
|
log.error("[TABLING] Bulk search error", e);
|
|
emitter.completeWithError(e);
|
|
}
|
|
});
|
|
|
|
return emitter;
|
|
}
|
|
|
|
/** 테이블링 URL 저장 */
|
|
@PutMapping("/{id}/tabling-url")
|
|
public Map<String, Object> setTablingUrl(@PathVariable String id, @RequestBody Map<String, String> body) {
|
|
AuthUtil.requireAdmin();
|
|
var r = restaurantService.findById(id);
|
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
|
String url = body.get("tabling_url");
|
|
// #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
|
|
// Naver/DDG 결과가 www.tabling.co.kr 형태로도 옴.
|
|
if (url != null && !url.isBlank()
|
|
&& !url.startsWith("https://tabling.co.kr/")
|
|
&& !url.startsWith("https://www.tabling.co.kr/")) {
|
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "테이블링 URL은 https://(www.)tabling.co.kr/ 만 허용");
|
|
}
|
|
restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
|
|
cache.flush();
|
|
return Map.of("ok", true);
|
|
}
|
|
|
|
/** 테이블링/캐치테이블 매핑 초기화 */
|
|
@DeleteMapping("/reset-tabling")
|
|
public Map<String, Object> resetTabling() {
|
|
AuthUtil.requireAdmin();
|
|
restaurantService.resetTablingUrls();
|
|
cache.flush();
|
|
return Map.of("ok", true);
|
|
}
|
|
|
|
@DeleteMapping("/reset-catchtable")
|
|
public Map<String, Object> resetCatchtable() {
|
|
AuthUtil.requireAdmin();
|
|
restaurantService.resetCatchtableUrls();
|
|
cache.flush();
|
|
return Map.of("ok", true);
|
|
}
|
|
|
|
/** 단건 캐치테이블 URL 검색 */
|
|
@GetMapping("/{id}/catchtable-search")
|
|
public List<Map<String, Object>> catchtableSearch(@PathVariable String id) {
|
|
AuthUtil.requireAdmin();
|
|
var r = restaurantService.findById(id);
|
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
|
try {
|
|
return searchCatchtable(r.getName());
|
|
} catch (Exception e) {
|
|
log.error("[CATCHTABLE] Search failed for '{}': {}", r.getName(), e.getMessage());
|
|
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
|
|
}
|
|
}
|
|
|
|
/** 캐치테이블 미연결 식당 목록 */
|
|
@GetMapping("/catchtable-pending")
|
|
public Map<String, Object> catchtablePending() {
|
|
AuthUtil.requireAdmin();
|
|
var list = restaurantService.findWithoutCatchtable();
|
|
var summary = list.stream()
|
|
.map(r -> Map.of("id", (Object) r.getId(), "name", (Object) r.getName()))
|
|
.toList();
|
|
return Map.of("count", list.size(), "restaurants", summary);
|
|
}
|
|
|
|
/** 벌크 캐치테이블 검색 (SSE) */
|
|
@PostMapping("/bulk-catchtable")
|
|
public SseEmitter bulkCatchtable() {
|
|
AuthUtil.requireAdmin();
|
|
SseEmitter emitter = new SseEmitter(10_800_000L); // 3h — 대량 백필 대응
|
|
|
|
executor.execute(() -> {
|
|
try {
|
|
var restaurants = restaurantService.findWithoutCatchtable();
|
|
int total = restaurants.size();
|
|
emit(emitter, Map.of("type", "start", "total", total));
|
|
|
|
if (total == 0) {
|
|
emit(emitter, Map.of("type", "complete", "total", 0, "linked", 0, "notFound", 0));
|
|
emitter.complete();
|
|
return;
|
|
}
|
|
|
|
int linked = 0;
|
|
int notFound = 0;
|
|
|
|
for (int i = 0; i < total; i++) {
|
|
var r = restaurants.get(i);
|
|
emit(emitter, Map.of("type", "processing", "current", i + 1,
|
|
"total", total, "name", r.getName()));
|
|
|
|
try {
|
|
var results = searchCatchtable(r.getName());
|
|
if (!results.isEmpty()) {
|
|
String url = String.valueOf(results.get(0).get("url"));
|
|
String title = String.valueOf(results.get(0).get("title"));
|
|
if (isNameSimilar(r.getName(), title)) {
|
|
restaurantService.update(r.getId(), Map.of("catchtable_url", url));
|
|
linked++;
|
|
emit(emitter, Map.of("type", "done", "current", i + 1,
|
|
"name", r.getName(), "url", url, "title", title));
|
|
} else {
|
|
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
|
|
notFound++;
|
|
log.info("[CATCHTABLE] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
|
|
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
|
"name", r.getName(), "reason", "이름 불일치: " + title));
|
|
}
|
|
} else {
|
|
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
|
|
notFound++;
|
|
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
|
"name", r.getName()));
|
|
}
|
|
} catch (Exception e) {
|
|
notFound++;
|
|
emit(emitter, Map.of("type", "error", "current", i + 1,
|
|
"name", r.getName(), "message", e.getMessage()));
|
|
}
|
|
|
|
int delay = ThreadLocalRandom.current().nextInt(2000, 5001);
|
|
log.info("[CATCHTABLE] Waiting {}ms before next search...", delay);
|
|
Thread.sleep(delay);
|
|
}
|
|
|
|
cache.flush();
|
|
emit(emitter, Map.of("type", "complete", "total", total, "linked", linked, "notFound", notFound));
|
|
emitter.complete();
|
|
} catch (Exception e) {
|
|
log.error("[CATCHTABLE] Bulk search error", e);
|
|
emitter.completeWithError(e);
|
|
}
|
|
});
|
|
|
|
return emitter;
|
|
}
|
|
|
|
/** 캐치테이블 URL 저장 */
|
|
@PutMapping("/{id}/catchtable-url")
|
|
public Map<String, Object> setCatchtableUrl(@PathVariable String id, @RequestBody Map<String, String> body) {
|
|
AuthUtil.requireAdmin();
|
|
var r = restaurantService.findById(id);
|
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
|
String url = body.get("catchtable_url");
|
|
// #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
|
|
if (url != null && !url.isBlank()
|
|
&& !url.startsWith("https://app.catchtable.co.kr/")
|
|
&& !url.startsWith("https://www.catchtable.co.kr/")) {
|
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "캐치테이블 URL은 https://(app|www).catchtable.co.kr/ 만 허용");
|
|
}
|
|
restaurantService.update(id, Map.of("catchtable_url", url != null ? url : ""));
|
|
cache.flush();
|
|
return Map.of("ok", true);
|
|
}
|
|
|
|
@GetMapping("/{id}/videos")
|
|
public List<Map<String, Object>> videos(
|
|
@PathVariable String id,
|
|
@RequestParam(name = "include_weak", defaultValue = "false") boolean includeWeak) {
|
|
String key = cache.makeKey("restaurant_videos", id, includeWeak ? "all" : "strong");
|
|
String cached = cache.getRaw(key);
|
|
if (cached != null) {
|
|
try {
|
|
return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {});
|
|
} catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
|
|
}
|
|
var r = restaurantService.findById(id);
|
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
|
var result = restaurantService.findVideoLinks(id, includeWeak);
|
|
cache.set(key, result);
|
|
return result;
|
|
}
|
|
|
|
// ─── 예약 사이트 URL 검색 (#357 WebSearchService: Naver primary + DDG fallback) ───
|
|
|
|
private List<Map<String, Object>> searchTabling(String restaurantName) {
|
|
return webSearch.search(
|
|
"site:tabling.co.kr " + restaurantName,
|
|
"tabling.co.kr/restaurant/", "tabling.co.kr/place/"
|
|
);
|
|
}
|
|
|
|
private List<Map<String, Object>> searchCatchtable(String restaurantName) {
|
|
// 실제 캐치테이블 URL은 /ct/shop/ 형식. 옛 /dining/ /shop/ 패턴은 매칭 실패.
|
|
return webSearch.search(
|
|
"site:app.catchtable.co.kr " + restaurantName,
|
|
"catchtable.co.kr/ct/shop/", "catchtable.co.kr/ct/dining/"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 식당 이름과 검색 결과 제목의 유사도 검사.
|
|
* 한쪽 이름이 다른쪽에 포함되거나, 공통 글자 비율이 40% 이상이면 유사하다고 판단.
|
|
*/
|
|
/**
|
|
* #348 — 한국어 자모 분해 + Sørensen-Dice bigram 유사도(임계값 0.45).
|
|
* 짧은 한국어 이름에서 이전 Jaccard-like(set 비율) 방식보다 정확.
|
|
*/
|
|
private boolean isNameSimilar(String restaurantName, String resultTitle) {
|
|
return com.tasteby.util.HangulSimilarity.similarity(restaurantName, resultTitle) >= 0.45;
|
|
}
|
|
|
|
private void emit(SseEmitter emitter, Map<String, Object> data) {
|
|
try {
|
|
emitter.send(SseEmitter.event().data(objectMapper.writeValueAsString(data)));
|
|
} catch (Exception e) {
|
|
log.debug("SSE emit error: {}", e.getMessage());
|
|
}
|
|
}
|
|
}
|