Compare commits

..

9 Commits

Author SHA1 Message Date
joungmin
7789671fbc feat(map): 식당 상세 지도 링크 국내/해외 분기 (1단계)
- 좌표 기반 한국 판정 (KR bbox 33~38.7°N, 124~132°E)
- 국내: 네이버 지도(/p/search/) primary + Google Maps 보조
- 해외: Google Maps 단독
- 좌표 없으면 region 첫 토큰 fallback

2단계(메인 지도 탭 SDK 분기)는 별도 후속.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 05:59:24 +09:00
joungmin
c5b0216a37 fix(catchtable): URL 패턴을 /ct/shop/, /ct/dining/으로 교정
- 실제 캐치테이블은 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>
2026-06-16 01:35:18 +09:00
joungmin
40e448fe95 fix(search): WebSearchService HTTP timeout 추가 (connect 5s, request 15s)
- 특정 검색에서 무한 hang → backend virtual thread 점유로 후속 벌크 작업 중단
- Naver/DDG 둘 다 timeout 적용
- 타임아웃 시 HttpTimeoutException → 호출자(bulk)에서 notfound 안전 처리

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 21:52:31 +09:00
joungmin
8a21646031 fix(admin): bulk-tabling/catchtable SSE timeout 10분 → 3시간
- 대량 백필(700+건 ≈ 100분) 시 10분 SSE timeout으로 중간 끊김
- emit() 실패 시 마지막 cache.flush + complete 누락 → 3시간으로 확장

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 21:08:55 +09:00
joungmin
52090057de fix(restaurant): #357 후속 — tabling-url validation에 www. 호스트 허용
- Naver/DDG 결과가 www.tabling.co.kr 형태인데 PUT validation에서 거부됨
- bulk-tabling SSE는 validation 없이 통과 — 불일치 해소
- catchtable은 이미 app/www 둘 다 허용 (기존)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 21:02:32 +09:00
joungmin
d73947444f feat(backend): #359 1단계 — google_place_id 중복 조회 API
- GET /api/admin/restaurants/duplicates/place-id (어드민 전용)
- 그룹별 식당 목록 + video/review/memo 카운트 동봉
- Mapper: findDuplicatePlaceIdRows + Service 그룹핑
- 정리/병합 + UNIQUE 제약은 데이터 위험 분리 위해 후속 PR로

Refs: #359 (조회 단계 완료)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 20:32:40 +09:00
joungmin
c1050f3abd feat(backend): #358 RestaurantUpdateDTO + @Valid 표준화
- dto/RestaurantUpdateDTO record 신규 (15 필드, 모두 nullable)
- @Size/@Pattern(URL or NONE)/@DecimalMin·Max/@Min·Max
- RestaurantController.update 시그니처 Map → @Valid DTO 교체
- toFieldMap()으로 null 제외 후 기존 Service.update 호출 (회귀 0)
- #332 ALLOWED_UPDATE_FIELDS Set 제거 (DTO 필드 자체가 화이트리스트)

Refs: #358 (close)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 20:20:51 +09:00
joungmin
a504bf8ee5 feat(backend): #357 DDG → Naver Search 정식 API + DDG 폴백
- WebSearchService 신규 (Naver webkr.json 우선, 키 미설정/실패 시 DDG)
- RestaurantController.searchTabling/searchCatchtable 내부 호출 교체
- 인라인 DDG 80줄 제거, 미사용 import 정리
- app.naver.client-id/secret 추가 (env: NAVER_CLIENT_ID/SECRET)
- k8s secrets template에 NAVER 키 항목

Refs: #357 (close)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 20:16:14 +09:00
joungmin
f1164b63c5 docs(design): #357 정식 검색 API 전환 설계서 (Architect)
- WebSearchService 신규: Naver Search webkr.json 우선, DDG 폴백
- searchTabling/searchCatchtable 내부 호출만 교체 (시그니처 유지)
- application.yml + k8s secrets에 NAVER_CLIENT_ID/SECRET 추가

Refs: #357

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 20:11:52 +09:00
14 changed files with 653 additions and 128 deletions

View File

@@ -4,8 +4,62 @@
---
## 2026-06-16
### 🗺️ 식당 상세 지도 링크 국내/해외 분기 (v0.1.51)
- 좌표 기반 한국 판정 (WGS84 KR bbox 33~38.7°N, 124~132°E)
- 국내: 네이버 지도 primary + Google Maps 보조 (네이버 URL은 신 도메인 /p/search/)
- 해외: Google Maps 단독
- 좌표 없으면 region 첫 토큰 fallback (구 데이터 호환)
- frontend-only 배포
## 2026-06-15
### 🐛 캐치테이블 URL 패턴 수정 (v0.1.50)
- 실제 catchtable URL은 `app.catchtable.co.kr/ct/shop/...` 형식 (옛 `/shop/`, `/dining/`은 매칭 실패)
- 첫 회차(v0.1.49) 캐치테이블 벌크 결과 1044건 전부 미발견(매핑 0%)의 원인
- 패턴을 `catchtable.co.kr/ct/shop/`, `catchtable.co.kr/ct/dining/`로 교정 후 NONE 해제 + 재실행
### 🐛 WebSearchService HTTP timeout 추가 (v0.1.49)
- 벌크 백필 중 특정 검색에서 무한 hang → backend executor virtual thread 점유로 후속 작업 중단 (90건 처리 후 멈춤)
- connectTimeout=5s + request timeout=15s (Naver/DDG 둘 다)
- 해당 식당은 HttpTimeoutException → notfound로 안전 처리
### ⏱️ bulk-tabling/catchtable SSE timeout 10분 → 3시간 (v0.1.48)
- 대량 백필(724건 ≈ 100분) 시 10분 SSE timeout으로 중간 끊김 → 3시간으로 확장
- 백엔드 작업은 virtual thread로 별도 진행됐지만 emit() 예외로 마지막 cache.flush + complete 누락이슈 해소
### 🐛 #357 후속 — tabling-url validation에 www. 호스트 허용 (v0.1.47)
- Naver/DDG 결과가 `https://www.tabling.co.kr/...` 형태인데 #290 validation은 `tabling.co.kr/`만 허용 → 단건 매핑 PUT 거부
- bulk-tabling SSE는 validation 없이 service.update 직접 호출이라 통과 → 단일/벌크 불일치
- `www.tabling.co.kr` prefix도 허용 (catchtable은 이미 app/www 둘 다 허용)
- 시연 등록: bbq 부천은하마을점 → BBQ 치킨 부천은하마을점
### 🔍 #359 1단계 — google_place_id 중복 조회 API (v0.1.46)
- GET /api/admin/restaurants/duplicates/place-id (어드민 전용)
- 응답: 그룹별 식당 + video/review/memo 카운트 (병합 의사결정 자료)
- 정리/병합 + UNIQUE 제약은 별도 PR (데이터 위험 분리)
- 설계서: docs/design/359a-duplicate-place-id-view/README.md
- Refs: #359 (조회 단계 완료, 후속 분리 유지)
### 📋 #358 RestaurantUpdateDTO + @Valid 표준화 (v0.1.45)
- dto/RestaurantUpdateDTO record 신규 (15 필드, 모두 nullable)
- Bean Validation: @Size/@Pattern(URL or NONE)/@DecimalMin·Max/@Min·Max
- RestaurantController.update 시그니처 Map → @Valid DTO 교체
- toFieldMap()으로 null 제외 후 기존 Service.update 호출 (회귀 0)
- #332 ALLOWED_UPDATE_FIELDS Set 제거 (DTO 필드 자체가 화이트리스트)
- 설계서: docs/design/358-restaurant-update-dto/README.md
- Refs: #358 (close)
### 🔎 #357 DDG → Naver Search 정식 API + DDG 폴백 (v0.1.44)
- WebSearchService 신규 (Naver webkr.json 우선, 키 미설정/실패 시 DDG 폴백)
- RestaurantController.searchTabling/searchCatchtable 내부 호출 교체, DDG 인라인 80줄 제거
- application.yml: app.naver.client-id/secret (NAVER_CLIENT_ID/SECRET 환경변수)
- k8s/secrets.yaml.template에 NAVER_CLIENT_ID/SECRET 항목 추가
- 미사용 import 정리 (HttpClient/URI/URLEncoder/Pattern 등 RestaurantController에서)
- 설계서: docs/design/357-web-search-api/README.md
- Refs: #357 (close)
### 🎯 #356 영상-식당 관련도 LLM 평가 (v0.1.43)
- DB: video_restaurants 컬럼 추가 (relevance/relevance_reason/relevance_evaluated_at) + idx_vr_relevance
- VideoRelevanceService 신규 (#322 RestaurantVerifyService 패턴 모방, @Async verifyAsync/verify/verifyAll)

View File

@@ -62,6 +62,15 @@ public class AdminRestaurantController {
return Map.of("success", true, "id", id);
}
// #359 1단계 — google_place_id 중복 조회 (정리/UNIQUE는 후속).
@GetMapping("/duplicates/place-id")
public Map<String, Object> duplicatePlaceIds() {
var admin = AuthUtil.requireAdmin();
var groups = restaurantService.findDuplicatePlaceIdGroups();
log.info("[ADMIN] {} duplicate place_id groups: {}", admin.getSubject(), groups.size());
return Map.of("groups", groups, "group_count", groups.size());
}
/**
* 어드민용 hidden 토글.
*/

View File

@@ -4,9 +4,12 @@ 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;
@@ -15,19 +18,10 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.net.URI;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@RestController
@RequestMapping("/api/restaurants")
@@ -39,13 +33,15 @@ public class RestaurantController {
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) {
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를 정리하여 리소스 누수 방지.
@@ -90,30 +86,14 @@ public class RestaurantController {
return r;
}
// #332 — Restaurant 업데이트 화이트리스트 (SQL updateFields의 컬럼 가드와 1:1).
// 허용되지 않은 키는 무시(silent drop). DTO 도입은 후속 작업.
private static final java.util.Set<String> ALLOWED_UPDATE_FIELDS = java.util.Set.of(
"name", "address", "region", "cuisine_type", "price_range",
"phone", "website", "tabling_url", "catchtable_url",
"latitude", "longitude", "google_place_id",
"business_status", "rating", "rating_count"
);
@PutMapping("/{id}")
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
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");
// #332입력 body를 허용 키만 통과시킨 가변 Map으로 정규화
Map<String, Object> sanitized = new java.util.LinkedHashMap<>();
for (var e : body.entrySet()) {
if (ALLOWED_UPDATE_FIELDS.contains(e.getKey())) {
sanitized.put(e.getKey(), e.getValue());
} else {
log.debug("Ignoring non-whitelisted update field: {}", e.getKey());
}
}
// #358DTO → Map (null 제외). 화이트리스트는 DTO 필드 자체로 표현.
Map<String, Object> sanitized = dto.toFieldMap();
// Re-geocode if name or address changed
String newName = (String) sanitized.get("name");
@@ -195,7 +175,7 @@ public class RestaurantController {
@PostMapping("/bulk-tabling")
public SseEmitter bulkTabling() {
AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L);
SseEmitter emitter = new SseEmitter(10_800_000L); // 3h — 대량 백필 대응
executor.execute(() -> {
try {
@@ -272,8 +252,11 @@ public class RestaurantController {
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
String url = body.get("tabling_url");
// #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
if (url != null && !url.isBlank() && !url.startsWith("https://tabling.co.kr/")) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "테이블링 URL은 https://tabling.co.kr/ 만 허용");
// 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();
@@ -326,7 +309,7 @@ public class RestaurantController {
@PostMapping("/bulk-catchtable")
public SseEmitter bulkCatchtable() {
AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L);
SseEmitter emitter = new SseEmitter(10_800_000L); // 3h — 대량 백필 대응
executor.execute(() -> {
try {
@@ -430,95 +413,20 @@ public class RestaurantController {
return result;
}
// ─── DuckDuckGo HTML search helpers ─────────────────────────────────
// ─── 예약 사이트 URL 검색 (#357 WebSearchService: Naver primary + DDG fallback) ───
private static final HttpClient httpClient = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
private static final Pattern DDG_RESULT_PATTERN = Pattern.compile(
"<a[^>]+class=\"result__a\"[^>]+href=\"([^\"]+)\"[^>]*>(.*?)</a>",
Pattern.DOTALL
);
/**
* DuckDuckGo HTML 검색을 통해 특정 사이트의 URL을 찾는다.
* html.duckduckgo.com은 서버사이드 렌더링이라 봇 판정 없이 HTTP 요청만으로 결과를 파싱할 수 있다.
*/
private List<Map<String, Object>> searchDuckDuckGo(String query, String... urlPatterns) throws Exception {
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
String searchUrl = "https://html.duckduckgo.com/html/?q=" + encoded;
log.info("[DDG] Searching: {}", query);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(searchUrl))
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
.header("Accept", "text/html,application/xhtml+xml")
.header("Accept-Language", "ko-KR,ko;q=0.9")
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
String html = response.body();
List<Map<String, Object>> results = new ArrayList<>();
Set<String> seen = new HashSet<>();
Matcher matcher = DDG_RESULT_PATTERN.matcher(html);
while (matcher.find() && results.size() < 5) {
String href = matcher.group(1);
String title = matcher.group(2).replaceAll("<[^>]+>", "").trim();
// DDG 링크에서 실제 URL 추출 (uddg 파라미터)
String actualUrl = extractDdgUrl(href);
if (actualUrl == null) continue;
boolean matches = false;
for (String pattern : urlPatterns) {
if (actualUrl.contains(pattern)) {
matches = true;
break;
}
}
if (matches && !seen.contains(actualUrl)) {
seen.add(actualUrl);
results.add(Map.of("title", title, "url", actualUrl));
}
}
log.info("[DDG] Found {} results for '{}'", results.size(), query);
return results;
}
/** DDG 리다이렉트 URL에서 실제 URL 추출 */
private String extractDdgUrl(String ddgHref) {
try {
// //duckduckgo.com/l/?uddg=ENCODED_URL&rut=...
if (ddgHref.contains("uddg=")) {
String uddgParam = ddgHref.substring(ddgHref.indexOf("uddg=") + 5);
int ampIdx = uddgParam.indexOf('&');
if (ampIdx > 0) uddgParam = uddgParam.substring(0, ampIdx);
return URLDecoder.decode(uddgParam, StandardCharsets.UTF_8);
}
// 직접 URL인 경우
if (ddgHref.startsWith("http")) return ddgHref;
} catch (Exception e) {
log.debug("[DDG] Failed to extract URL from: {}", ddgHref);
}
return null;
}
private List<Map<String, Object>> searchTabling(String restaurantName) throws Exception {
return searchDuckDuckGo(
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) throws Exception {
return searchDuckDuckGo(
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/dining/", "catchtable.co.kr/shop/"
"catchtable.co.kr/ct/shop/", "catchtable.co.kr/ct/dining/"
);
}

View File

@@ -0,0 +1,94 @@
package com.tasteby.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* #358 식당 부분 업데이트 DTO.
* - null = 변경 없음 (toFieldMap에서 제외).
* - 화이트리스트는 record 필드로 표현 — Jackson SNAKE_CASE 매핑 유지.
* - URL: http(s) / "NONE" / 빈 문자열만 허용 ("NONE"은 DDG/Naver 매칭 실패 마킹).
*/
public record RestaurantUpdateDTO(
@Size(min = 1, max = 200)
String name,
@Size(max = 500)
String address,
@Size(max = 100)
String region,
@JsonProperty("cuisine_type")
@Size(max = 50)
String cuisineType,
@JsonProperty("price_range")
@Min(1) @Max(5)
Integer priceRange,
@Size(max = 50)
String phone,
@Pattern(regexp = "^(https?://.*|NONE|)$")
String website,
@JsonProperty("tabling_url")
@Pattern(regexp = "^(https?://.*|NONE|)$")
String tablingUrl,
@JsonProperty("catchtable_url")
@Pattern(regexp = "^(https?://.*|NONE|)$")
String catchtableUrl,
@DecimalMin("-90.0") @DecimalMax("90.0")
BigDecimal latitude,
@DecimalMin("-180.0") @DecimalMax("180.0")
BigDecimal longitude,
@JsonProperty("google_place_id")
@Size(max = 200)
String googlePlaceId,
@JsonProperty("business_status")
@Size(max = 50)
String businessStatus,
@DecimalMin("0.0") @DecimalMax("5.0")
BigDecimal rating,
@JsonProperty("rating_count")
@Min(0)
Integer ratingCount
) {
/** null이 아닌 필드만 DB 컬럼명 키로 변환. */
public Map<String, Object> toFieldMap() {
Map<String, Object> m = new LinkedHashMap<>();
if (name != null) m.put("name", name);
if (address != null) m.put("address", address);
if (region != null) m.put("region", region);
if (cuisineType != null) m.put("cuisine_type", cuisineType);
if (priceRange != null) m.put("price_range", priceRange);
if (phone != null) m.put("phone", phone);
if (website != null) m.put("website", website);
if (tablingUrl != null) m.put("tabling_url", tablingUrl);
if (catchtableUrl != null) m.put("catchtable_url", catchtableUrl);
if (latitude != null) m.put("latitude", latitude);
if (longitude != null) m.put("longitude", longitude);
if (googlePlaceId != null) m.put("google_place_id", googlePlaceId);
if (businessStatus != null) m.put("business_status", businessStatus);
if (rating != null) m.put("rating", rating);
if (ratingCount != null) m.put("rating_count", ratingCount);
return m;
}
}

View File

@@ -39,6 +39,9 @@ public interface RestaurantMapper {
int countUnevaluatedLinks();
// #359 1단계 — google_place_id 중복 조회
List<Map<String, Object>> findDuplicatePlaceIdRows();
Restaurant findById(@Param("id") String id);
List<Map<String, Object>> findVideoLinks(@Param("restaurantId") String restaurantId,

View File

@@ -111,6 +111,23 @@ public class RestaurantService {
return mapper.countUnevaluatedLinks();
}
// #359 1단계 — google_place_id 중복 그룹 (참조 카운트 동봉)
public List<Map<String, Object>> findDuplicatePlaceIdGroups() {
var rows = mapper.findDuplicatePlaceIdRows().stream()
.map(JsonUtil::lowerKeys)
.toList();
Map<String, List<Map<String, Object>>> grouped = new LinkedHashMap<>();
for (var r : rows) {
String key = (String) r.get("google_place_id");
grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(r);
}
List<Map<String, Object>> out = new ArrayList<>(grouped.size());
for (var e : grouped.entrySet()) {
out.add(Map.of("google_place_id", e.getKey(), "items", e.getValue()));
}
return out;
}
public void update(String id, Map<String, Object> fields) {
mapper.updateFields(id, fields);
}

View File

@@ -0,0 +1,159 @@
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 java.net.URI;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* #357 웹 검색 추상화.
* - Naver Search webkr.json 우선 (한국 식당 정확도 높음, 무료 일 25k).
* - 키 미설정 또는 5xx/timeout 시 DDG HTML 파싱으로 폴백.
* - 결과는 urlPatterns로 필터링 (기존 searchDuckDuckGo와 동일 인터페이스).
*/
@Service
public class WebSearchService {
private static final Logger log = LoggerFactory.getLogger(WebSearchService.class);
private static final int MAX_RESULTS = 5;
private static final Duration REQ_TIMEOUT = Duration.ofSeconds(15);
private static final HttpClient HTTP = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(5))
.build();
private static final Pattern DDG_RESULT = Pattern.compile(
"<a[^>]+class=\"result__a\"[^>]+href=\"([^\"]+)\"[^>]*>(.*?)</a>",
Pattern.DOTALL);
private final ObjectMapper json = new ObjectMapper();
private final String naverClientId;
private final String naverClientSecret;
public WebSearchService(
@Value("${app.naver.client-id:}") String naverClientId,
@Value("${app.naver.client-secret:}") String naverClientSecret) {
this.naverClientId = naverClientId == null ? "" : naverClientId.trim();
this.naverClientSecret = naverClientSecret == null ? "" : naverClientSecret.trim();
log.info("WebSearchService init — Naver={}", naverClientId.isEmpty() ? "off" : "on");
}
public List<Map<String, Object>> search(String query, String... urlPatterns) {
if (!naverClientId.isEmpty() && !naverClientSecret.isEmpty()) {
try {
List<Map<String, Object>> n = searchNaver(query, urlPatterns);
if (!n.isEmpty()) return n;
} catch (Exception e) {
log.warn("[NaverSearch] failed, falling back to DDG: {}", e.getMessage());
}
}
try {
return searchDdg(query, urlPatterns);
} catch (Exception e) {
log.warn("[DDG] failed: {}", e.getMessage());
return List.of();
}
}
// ─── Naver ───
List<Map<String, Object>> searchNaver(String query, String... urlPatterns) throws Exception {
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
String url = "https://openapi.naver.com/v1/search/webkr.json?query=" + encoded + "&display=30";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(REQ_TIMEOUT)
.header("X-Naver-Client-Id", naverClientId)
.header("X-Naver-Client-Secret", naverClientSecret)
.GET()
.build();
HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() >= 400) {
throw new RuntimeException("Naver " + resp.statusCode());
}
JsonNode root = json.readTree(resp.body());
JsonNode items = root.path("items");
List<Map<String, Object>> out = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (JsonNode it : items) {
if (out.size() >= MAX_RESULTS) break;
String link = it.path("link").asText("");
String title = stripTags(it.path("title").asText(""));
if (link.isEmpty() || !matchesPattern(link, urlPatterns)) continue;
if (seen.add(link)) out.add(Map.of("title", title, "url", link));
}
log.info("[NaverSearch] '{}' → {}", query, out.size());
return out;
}
// ─── DDG ───
List<Map<String, Object>> searchDdg(String query, String... urlPatterns) throws Exception {
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
String url = "https://html.duckduckgo.com/html/?q=" + encoded;
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(REQ_TIMEOUT)
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
.header("Accept", "text/html,application/xhtml+xml")
.header("Accept-Language", "ko-KR,ko;q=0.9")
.GET()
.build();
HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
String html = resp.body();
Matcher m = DDG_RESULT.matcher(html);
List<Map<String, Object>> out = new ArrayList<>();
Set<String> seen = new HashSet<>();
while (m.find() && out.size() < MAX_RESULTS) {
String href = m.group(1);
String title = m.group(2).replaceAll("<[^>]+>", "").trim();
String actual = extractDdgUrl(href);
if (actual == null || !matchesPattern(actual, urlPatterns)) continue;
if (seen.add(actual)) out.add(Map.of("title", title, "url", actual));
}
log.info("[DDG] '{}' → {}", query, out.size());
return out;
}
private String extractDdgUrl(String ddgHref) {
try {
if (ddgHref.contains("uddg=")) {
String p = ddgHref.substring(ddgHref.indexOf("uddg=") + 5);
int amp = p.indexOf('&');
if (amp > 0) p = p.substring(0, amp);
return URLDecoder.decode(p, StandardCharsets.UTF_8);
}
if (ddgHref.startsWith("http")) return ddgHref;
} catch (Exception e) {
log.debug("[DDG] url extract failed: {}", ddgHref);
}
return null;
}
static String stripTags(String s) {
return s == null ? "" : s.replaceAll("<[^>]+>", "").trim();
}
static boolean matchesPattern(String url, String[] patterns) {
if (patterns == null || patterns.length == 0) return true;
for (String p : patterns) {
if (url.contains(p)) return true;
}
return false;
}
}

View File

@@ -56,6 +56,11 @@ app:
youtube-api-key: ${YOUTUBE_DATA_API_KEY}
client-id: ${GOOGLE_CLIENT_ID:635551099330-2l003d3ernjmkqavd4f6s78r8r405iml.apps.googleusercontent.com}
# #357 — Naver Search API (Tabling/Catchtable URL 매칭). 미설정 시 DDG 폴백.
naver:
client-id: ${NAVER_CLIENT_ID:}
client-secret: ${NAVER_CLIENT_SECRET:}
cache:
ttl-seconds: 600

View File

@@ -353,4 +353,21 @@
SELECT COUNT(*) FROM video_restaurants WHERE relevance_evaluated_at IS NULL
</select>
<!-- #359 1단계 — google_place_id 중복 조회 (그룹 식당 + 참조 카운트) -->
<select id="findDuplicatePlaceIdRows" resultType="map">
SELECT r.id, r.google_place_id, r.name, r.address,
TO_CHAR(r.created_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS created_at,
r.hidden,
(SELECT COUNT(*) FROM video_restaurants vr WHERE vr.restaurant_id = r.id) AS video_count,
(SELECT COUNT(*) FROM user_reviews rv WHERE rv.restaurant_id = r.id) AS review_count,
(SELECT COUNT(*) FROM user_memos mm WHERE mm.restaurant_id = r.id) AS memo_count
FROM restaurants r
WHERE r.google_place_id IN (
SELECT google_place_id FROM restaurants
WHERE google_place_id IS NOT NULL
GROUP BY google_place_id HAVING COUNT(*) > 1
)
ORDER BY r.google_place_id, r.created_at
</select>
</mapper>

View File

@@ -0,0 +1,114 @@
# 설계서: DDG HTML 파싱 → 정식 검색 API 전환 (#357)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #357 · 부모: #348(이름 유사도, 09-Done) · 관련: searchTabling/searchCatchtable
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/WebSearchService.java`(신규), `backend-java/src/main/java/com/tasteby/controller/RestaurantController.java`, `backend-java/src/main/resources/application.yml`, `k8s/secrets.yaml.template`
> · 테스트: 본 범위 밖 (수동 — Tabling/Catchtable 매핑 SSE에서 확인)
## 1. 목적 (Why)
`searchDuckDuckGo`는 html.duckduckgo.com을 정규식으로 긁어 Tabling/Catchtable URL을 추출. 봇 차단/HTML 구조 변경 시 대량 `NONE` 마킹 위험. 정식 검색 API로 교체해 안정성/정확도 확보.
## 2. 범위 (Scope)
- **포함**
- `WebSearchService` 신규 — Naver Search webkr.json 우선, 미설정/실패 시 DDG 폴백.
- `RestaurantController.searchTabling/searchCatchtable` 내부 호출을 새 서비스로 교체.
- `application.yml` + `k8s/secrets.yaml.template``NAVER_CLIENT_ID/SECRET` 항목 추가.
- **제외 (별도 후속)**
- Kakao Local API 검색 (식당 검색 전용이라 사이트 URL 매칭 부적합).
- Google Custom Search Engine (비용 + 무료 100/day 제약).
- 결과 캐시/메트릭 — 후속.
## 3. 인수조건
- [ ] `NAVER_CLIENT_ID/SECRET` 환경변수 등록 시 Naver Search webkr.json 호출.
- [ ] 미설정 또는 5xx/timeout 시 DDG로 자동 폴백 — 회귀 없음.
- [ ] 응답 형식: 기존 `List<Map<String, Object>>` 유지 — `{title, url}` 키.
- [ ] URL 패턴 필터 동일 동작 (예: `tabling.co.kr/restaurant/`, `tabling.co.kr/place/`).
- [ ] 빌드/배포 회귀 없음.
## 4. 컨텍스트 & 제약
- Naver Search 무료 한도: 일 25,000건. Tabling/Catchtable 매핑 백필이 1,200 식당 × 2회 ≈ 2,400건 1회성 → 한도 내.
- Naver는 `site:` 연산자 미지원 — 결과 URL 패턴 필터링으로 대체.
- `WebClient` 또는 `HttpClient` — 기존 `HttpClient`(static) 재사용.
- 키 미설정 환경(dev local 일부)에서도 DDG 폴백으로 동작 보장.
## 5. 아키텍처 개요
```
RestaurantController.searchTabling(name)
WebSearchService.search(query, urlPatterns)
├─ NAVER_CLIENT_ID 설정 시
│ └─ Naver webkr.json → URL 패턴 필터 → 결과
│ (실패/0건 시 ▼)
└─ DDG html.duckduckgo.com → 정규식 파싱 → URL 패턴 필터
```
## 6. 데이터 모델
### 응답 (기존 유지)
```json
[ { "title": "스타벅스 강남대로점", "url": "https://app.catchtable.co.kr/dining/12345" } ]
```
### Naver 응답 → 변환
```json
{ "items": [ { "title": "<b>스타벅스</b> 강남점", "link": "https://..." } ] }
```
- `title``<b>` 태그 제거 후 사용.
- `link`가 urlPatterns 중 하나에 매칭되면 결과에 포함.
## 7. 함수 명세
| 함수 | 책임 | 비고 |
|---|---|---|
| `WebSearchService.search(query, urlPatterns)` | 외부 진입점 | Naver 우선 → DDG 폴백 |
| `WebSearchService.searchNaver(...)` | Naver Search webkr.json 호출 + 필터 | 키 미설정 시 즉시 빈 결과 |
| `WebSearchService.searchDdg(...)` | 기존 DDG 로직 이관 | RestaurantController에서 옮김 |
| `WebSearchService.stripTags(s)` | `<b>...</b>` 제거 | |
| `WebSearchService.matchesPattern(url, patterns)` | URL 패턴 매칭 | `urlPatterns.length == 0` 면 모두 통과 |
## 8. 흐름
### 정상
1. `searchTabling(name)``webSearchService.search("스타벅스", ["tabling.co.kr/restaurant/", "tabling.co.kr/place/"])`.
2. Naver 호출 → 200, items 30 → URL 패턴 매칭 0~N건 추출.
3. 0건이면 DDG 폴백, 그래도 0건이면 빈 리스트.
### 폴백
- Naver 5xx / IOException / 키 미설정 → DDG.
### 키 등록 (운영자 작업)
1. https://developers.naver.com/apps/#/list 검색 앱 등록.
2. `NAVER_CLIENT_ID`, `NAVER_CLIENT_SECRET` 발급.
3. dev: `.env`에 추가, prod: `k8s/secrets.yaml`에 추가 + `kubectl apply`.
## 9. 엣지케이스
- **키 미설정**: searchNaver 즉시 빈 리스트 → DDG 폴백 (회귀 없음).
- **Naver rate limit (429)**: DDG 폴백.
- **DDG도 막힘**: 빈 리스트 반환 → 호출자(매핑 SSE)에서 `NONE` 처리 — 기존 동작 동일.
- **URL 패턴 빈 배열**: 패턴 매칭 스킵, 모든 결과 반환 (API 일반화 대비).
## 10. 테스트 (수동)
- Dev: `NAVER_CLIENT_ID/SECRET` 등록 → 어드민 `bulkTabling` SSE 실행 → 매칭율 비교 (이전 DDG 대비 ≥).
- 키 일부 제거 → DDG 폴백 동작 확인.
## 11. 리스크 & 대안
- **선택**: Naver primary + DDG fallback. 한국 식당 정확도 + 무료 한도 + 폴백 안정성.
- **대안 A**: Kakao Local Search — 식당 정보 직접 검색은 가능하지만 Tabling/Catchtable URL 매핑 부적합.
- **대안 B**: Google CSE — 비용/한도 제약.
- **트레이드오프**: Naver 응답 정확도가 압도적이지만 키 발급 운영자 작업 필요. 폴백으로 회귀 0 보장.
## 12. 미해결 질문
- Naver의 인덱스 신선도 vs Tabling/Catchtable 최신 입점 식당 미반영 가능성 — 백필 주기 후속.
- 결과 캐시(같은 식당 재호출) — 후속.

View File

@@ -0,0 +1,75 @@
# 설계서: RestaurantUpdateDTO + @Valid 표준화 (#358)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #358 · 부모: #348(09-Done) · 관련: #332(화이트리스트 1차)
> · 구현 파일: `backend-java/src/main/java/com/tasteby/dto/RestaurantUpdateDTO.java`(신규), `backend-java/src/main/java/com/tasteby/controller/RestaurantController.java`
> · 테스트: 본 범위 밖 (수동 — 어드민 식당 편집 동작 확인)
## 1. 목적 (Why)
`#332`에서 Set 화이트리스트로 1차 가드 적용했지만, 타입 안전성·validation·API 명세는 여전히 `Map<String, Object>`로 흐릿함. 본격 DTO 표준화로 잘못된 입력 자동 거부 + 명세 명확화.
## 2. 범위 (Scope)
- **포함**
- `RestaurantUpdateDTO` record — 화이트리스트 14필드 모두 Optional(null 시 미변경).
- `@Valid` + Bean Validation 어노테이션 적용 (`@Size`, `@Pattern`, `@DecimalMin/Max`, `@Min`).
- Controller `PUT /api/restaurants/{id}` 시그니처: `Map → RestaurantUpdateDTO`.
- DTO → Map 변환(`toFieldMap()`) — Service 계층은 그대로 (재작업 0).
- 잘못된 입력 시 400 자동 응답 (Spring 기본 `MethodArgumentNotValidException`).
- **제외 (별도 후속)**
- `tabling-url` / `catchtable-url` PUT 엔드포인트 — 단일 필드라 현행 유지.
- PATCH 시멘틱 (부분 업데이트) — 현재 PUT이 부분 업데이트 의미로 사용 중.
## 3. 인수조건
- [ ] 모든 화이트리스트 필드 record에 등재 + null 가능.
- [ ] `name`: `@Size(min=1, max=200)`.
- [ ] `website`/`tabling_url`/`catchtable_url`: `@Pattern(http(s)://... | "NONE" | "")`.
- [ ] `latitude`: `@DecimalMin("-90.0") @DecimalMax("90.0")`.
- [ ] `longitude`: `@DecimalMin("-180.0") @DecimalMax("180.0")`.
- [ ] `rating`: `@DecimalMin("0.0") @DecimalMax("5.0")`.
- [ ] `rating_count`: `@Min(0)`.
- [ ] `price_range`: `@Min(1) @Max(5)`.
- [ ] 잘못된 입력 → HTTP 400 자동 응답.
- [ ] 기존 동작 회귀 없음 (geocode/cache flush 흐름 동일).
## 4. 컨텍스트 & 제약
- `spring-boot-starter-validation` 이미 의존성 등록됨.
- record + Bean Validation: 컴파일 시 어노테이션 인식 OK.
- Jackson SNAKE_CASE 매핑 유지: `cuisine_type`, `tabling_url` 등.
- `null`은 "변경 없음" 시그널 — `toFieldMap()`에서 제외.
## 5. 함수 명세
| 함수 | 책임 | 비고 |
|---|---|---|
| `RestaurantUpdateDTO` (record) | 입력 표면 | 14 필드, 모두 nullable |
| `RestaurantUpdateDTO.toFieldMap()` | null 제외 Map 변환 | Service `update` 시그니처 유지 |
| `RestaurantController.update(...)` | DTO 받음 + geocode 분기 | `@Valid @RequestBody RestaurantUpdateDTO` |
## 6. 흐름
1. 클라이언트 → `PUT /api/restaurants/{id}` JSON.
2. Spring 역직렬화 + Bean Validation. 실패 시 400 자동.
3. `dto.toFieldMap()` → null 제외.
4. 기존 geocode 분기 + `restaurantService.update(id, fieldMap)`.
## 7. 엣지케이스
- **모든 필드 null**: `toFieldMap()` 빈 Map → no-op (현행 유지).
- **`tabling_url = "NONE"` / 빈 문자열**: Pattern에 포함 → 통과.
- **숫자 범위 위반**: 400.
- **알 수 없는 필드 (예: `xxx`)**: Jackson 기본은 무시 (mapper 설정 유지) → 안전.
## 8. 리스크 & 대안
- **선택**: record + Bean Validation. 코드 최소.
- **대안 A**: class + setter. 보일러플레이트 다수.
- **대안 B**: 개별 PATCH endpoint per 필드. 표면 폭증.
## 9. 미해결 질문
- bulkUpdate (batch) 도입 시 별도 DTO — 후속.

View File

@@ -0,0 +1,47 @@
# 설계서: google_place_id 중복 조회 API (#359 1단계)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #359 · 1단계(조회 전용, 위험 0). 2단계(자동 병합) / 3단계(UNIQUE)는 별도 PR.
> · 구현 파일: `backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml`, `backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java`, `backend-java/src/main/java/com/tasteby/service/RestaurantService.java`, `backend-java/src/main/java/com/tasteby/controller/AdminRestaurantController.java`
> · 테스트: 본 범위 밖 (수동 — admin token으로 호출).
## 1. 목적 (Why)
같은 `google_place_id`에 다중 식당이 매핑된 경우 운영자가 어떤 것을 유지/병합할지 결정 필요. 본 단계는 **조회만** — 그룹과 후보 식당을 메타데이터(연결된 영상/리뷰/메모 수)와 함께 보여줘 의사결정 자료 제공.
## 2. 범위
- 포함: `GET /api/admin/restaurants/duplicates/place-id` — 운영자만, 그룹별 식당 + 카운트 동봉.
- 제외 (별도 PR): 병합/삭제, UNIQUE constraint.
## 3. 인수조건
- [ ] requireAdmin 보호.
- [ ] 응답 구조: `[{ google_place_id, items: [{ id, name, address, created_at, video_count, review_count, memo_count, hidden }] }]`.
- [ ] 그룹은 `COUNT(*) > 1` 만 반환.
## 4. SQL
```sql
SELECT r.id, r.google_place_id, r.name, r.address,
TO_CHAR(r.created_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS created_at,
r.hidden,
(SELECT COUNT(*) FROM video_restaurants vr WHERE vr.restaurant_id = r.id) AS video_count,
(SELECT COUNT(*) FROM reviews rv WHERE rv.restaurant_id = r.id) AS review_count,
(SELECT COUNT(*) FROM memos mm WHERE mm.restaurant_id = r.id) AS memo_count
FROM restaurants r
WHERE r.google_place_id IN (
SELECT google_place_id FROM restaurants
WHERE google_place_id IS NOT NULL
GROUP BY google_place_id HAVING COUNT(*) > 1
)
ORDER BY r.google_place_id, r.created_at
```
Service 계층에서 google_place_id로 그룹핑하여 응답 구조 변환.
## 5. 엣지케이스
- 중복 0건 → 빈 배열.
- 누군가가 google_place_id를 동시에 변경 중 → 다음 호출에서 반영 (캐시 X).

View File

@@ -24,6 +24,15 @@ function buildSearchQuery(r: Restaurant): string {
return r.name;
}
// 좌표 기반 한국 판정 (WGS84). KR bbox 대략 33~38.7°N, 124~132°E.
// 좌표 없으면 region 첫 토큰으로 fallback (구 데이터 호환).
function isKoreaRestaurant(r: Restaurant): boolean {
if (r.latitude != null && r.longitude != null) {
return r.latitude >= 33 && r.latitude <= 38.7 && r.longitude >= 124 && r.longitude <= 132;
}
return !r.region || r.region.split("|")[0] === "한국";
}
export default function RestaurantDetail({
restaurant,
onClose,
@@ -138,6 +147,26 @@ export default function RestaurantDetail({
)}
{restaurant.google_place_id && (
<p className="flex gap-3">
{isKoreaRestaurant(restaurant) ? (
<>
<a
href={`https://map.naver.com/p/search/${encodeURIComponent(buildSearchQuery(restaurant))}`}
target="_blank"
rel="noopener noreferrer"
className="text-green-600 dark:text-green-400 hover:underline text-xs"
>
</a>
<a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(buildSearchQuery(restaurant))}`}
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:underline text-xs"
>
Google Maps
</a>
</>
) : (
<a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(buildSearchQuery(restaurant))}`}
target="_blank"
@@ -146,15 +175,6 @@ export default function RestaurantDetail({
>
Google Maps에서
</a>
{(!restaurant.region || restaurant.region.split("|")[0] === "한국") && (
<a
href={`https://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`}
target="_blank"
rel="noopener noreferrer"
className="text-green-600 dark:text-green-400 hover:underline text-xs"
>
</a>
)}
</p>
)}

View File

@@ -9,9 +9,12 @@ type: Opaque
stringData:
ORACLE_USER: "<oracle-username>"
ORACLE_PASSWORD: "<oracle-password>"
ORACLE_DSN: "<tns-alias>_high?TNS_ADMIN=/etc/oracle/wallet"
ORACLE_DSN: "<tns-alias>_medium?TNS_ADMIN=/etc/oracle/wallet"
JWT_SECRET: "<jwt-secret>"
OCI_COMPARTMENT_ID: "<oci-compartment-id>"
OCI_CHAT_MODEL_ID: "<oci-chat-model-id>"
GOOGLE_MAPS_API_KEY: "<google-maps-api-key>"
YOUTUBE_DATA_API_KEY: "<youtube-data-api-key>"
# #357 — Naver Search API (선택). 미설정 시 DDG 폴백.
NAVER_CLIENT_ID: "<naver-client-id>"
NAVER_CLIENT_SECRET: "<naver-client-secret>"