Files
tasteby/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java
joungmin cdee37e341 UI/UX 개선: 모바일 네비게이션, 로그인 모달, 지도 기능, 캐치테이블 연동
- 모바일 하단 네비게이션(홈/식당목록/내주변/찜/내정보) 추가
- 로그인 버튼을 모달 방식으로 변경 (소셜 로그인 확장 가능)
- 내위치 기반 정렬 및 영역 필터, 지도 내위치 버튼 추가
- 채널 필터 시 해당 채널만 마커/범례 표시
- 캐치테이블 검색/연동 (단건/벌크), NONE 저장 패턴
- 벌크 트랜스크립트 SSE (Playwright 브라우저 재사용)
- 테이블링/캐치테이블 버튼 UI 차별화
- Google Maps 링크 모바일 호환, 초기화 버튼, 검색 라벨 개선

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

465 lines
20 KiB
Java

package com.tasteby.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.microsoft.playwright.*;
import com.tasteby.domain.Restaurant;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.CacheService;
import com.tasteby.service.RestaurantService;
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.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
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 CacheService cache;
private final ObjectMapper objectMapper;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
public RestaurantController(RestaurantService restaurantService, CacheService cache, ObjectMapper objectMapper) {
this.restaurantService = restaurantService;
this.cache = cache;
this.objectMapper = objectMapper;
}
@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 ignored) {}
}
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 ignored) {}
}
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, @RequestBody Map<String, Object> body) {
AuthUtil.requireAdmin();
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
restaurantService.update(id, body);
cache.flush();
return Map.of("ok", true);
}
@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 (Playwright pw = Playwright.create()) {
try (Browser browser = launchBrowser(pw)) {
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
return searchTabling(page, 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(600_000L);
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;
try (Playwright pw = Playwright.create()) {
try (Browser browser = launchBrowser(pw)) {
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
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(page, r.getName());
if (!results.isEmpty()) {
String url = String.valueOf(results.get(0).get("url"));
String title = String.valueOf(results.get(0).get("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++;
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()));
}
// Google 봇 판정 방지 랜덤 딜레이 (5~15초)
int delay = ThreadLocalRandom.current().nextInt(5000, 15001);
log.info("[TABLING] Waiting {}ms before next search...", delay);
page.waitForTimeout(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");
restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
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 (Playwright pw = Playwright.create()) {
try (Browser browser = launchBrowser(pw)) {
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
return searchCatchtable(page, 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(600_000L);
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;
try (Playwright pw = Playwright.create()) {
try (Browser browser = launchBrowser(pw)) {
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
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(page, r.getName());
if (!results.isEmpty()) {
String url = String.valueOf(results.get(0).get("url"));
String title = String.valueOf(results.get(0).get("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++;
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(5000, 15001);
log.info("[CATCHTABLE] Waiting {}ms before next search...", delay);
page.waitForTimeout(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");
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) {
String key = cache.makeKey("restaurant_videos", id);
String cached = cache.getRaw(key);
if (cached != null) {
try {
return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {});
} catch (Exception ignored) {}
}
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
var result = restaurantService.findVideoLinks(id);
cache.set(key, result);
return result;
}
// ─── Playwright helpers ──────────────────────────────────────────────
private Browser launchBrowser(Playwright pw) {
return pw.chromium().launch(new BrowserType.LaunchOptions()
.setHeadless(false)
.setArgs(List.of("--disable-blink-features=AutomationControlled")));
}
private BrowserContext newContext(Browser browser) {
return browser.newContext(new Browser.NewContextOptions()
.setLocale("ko-KR").setViewportSize(1280, 900));
}
private Page newPage(BrowserContext ctx) {
Page page = ctx.newPage();
page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})");
return page;
}
@SuppressWarnings("unchecked")
private List<Map<String, Object>> searchTabling(Page page, String restaurantName) {
String query = "site:tabling.co.kr " + restaurantName;
log.info("[TABLING] Searching: {}", query);
String searchUrl = "https://www.google.com/search?q=" +
URLEncoder.encode(query, StandardCharsets.UTF_8);
page.navigate(searchUrl);
page.waitForTimeout(3000);
Object linksObj = page.evaluate("""
() => {
const results = [];
const links = document.querySelectorAll('a[href]');
for (const a of links) {
const href = a.href;
if (href.includes('tabling.co.kr/restaurant/') || href.includes('tabling.co.kr/place/')) {
const title = a.closest('[data-header-feature]')?.querySelector('h3')?.textContent
|| a.querySelector('h3')?.textContent
|| a.textContent?.trim()?.substring(0, 80)
|| '';
results.push({ title, url: href });
}
}
const seen = new Set();
return results.filter(r => {
if (seen.has(r.url)) return false;
seen.add(r.url);
return true;
}).slice(0, 5);
}
""");
List<Map<String, Object>> results = new ArrayList<>();
if (linksObj instanceof List<?> list) {
for (var item : list) {
if (item instanceof Map<?, ?> map) {
results.add(Map.of(
"title", String.valueOf(map.get("title")),
"url", String.valueOf(map.get("url"))
));
}
}
}
log.info("[TABLING] Found {} results for '{}'", results.size(), restaurantName);
return results;
}
@SuppressWarnings("unchecked")
private List<Map<String, Object>> searchCatchtable(Page page, String restaurantName) {
String query = "site:app.catchtable.co.kr " + restaurantName;
log.info("[CATCHTABLE] Searching: {}", query);
String searchUrl = "https://www.google.com/search?q=" +
URLEncoder.encode(query, StandardCharsets.UTF_8);
page.navigate(searchUrl);
page.waitForTimeout(3000);
Object linksObj = page.evaluate("""
() => {
const results = [];
const links = document.querySelectorAll('a[href]');
for (const a of links) {
const href = a.href;
if (href.includes('catchtable.co.kr/') && (href.includes('/dining/') || href.includes('/shop/'))) {
const title = a.closest('[data-header-feature]')?.querySelector('h3')?.textContent
|| a.querySelector('h3')?.textContent
|| a.textContent?.trim()?.substring(0, 80)
|| '';
results.push({ title, url: href });
}
}
const seen = new Set();
return results.filter(r => {
if (seen.has(r.url)) return false;
seen.add(r.url);
return true;
}).slice(0, 5);
}
""");
List<Map<String, Object>> results = new ArrayList<>();
if (linksObj instanceof List<?> list) {
for (var item : list) {
if (item instanceof Map<?, ?> map) {
results.add(Map.of(
"title", String.valueOf(map.get("title")),
"url", String.valueOf(map.get("url"))
));
}
}
}
log.info("[CATCHTABLE] Found {} results for '{}'", results.size(), restaurantName);
return results;
}
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());
}
}
}