Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5b0216a37 | ||
|
|
40e448fe95 | ||
|
|
8a21646031 | ||
|
|
52090057de | ||
|
|
d73947444f | ||
|
|
c1050f3abd | ||
|
|
a504bf8ee5 | ||
|
|
f1164b63c5 |
45
CHANGELOG.md
45
CHANGELOG.md
@@ -6,6 +6,51 @@
|
|||||||
|
|
||||||
## 2026-06-15
|
## 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)
|
### 🎯 #356 영상-식당 관련도 LLM 평가 (v0.1.43)
|
||||||
- DB: video_restaurants 컬럼 추가 (relevance/relevance_reason/relevance_evaluated_at) + idx_vr_relevance
|
- DB: video_restaurants 컬럼 추가 (relevance/relevance_reason/relevance_evaluated_at) + idx_vr_relevance
|
||||||
- VideoRelevanceService 신규 (#322 RestaurantVerifyService 패턴 모방, @Async verifyAsync/verify/verifyAll)
|
- VideoRelevanceService 신규 (#322 RestaurantVerifyService 패턴 모방, @Async verifyAsync/verify/verifyAll)
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ public class AdminRestaurantController {
|
|||||||
return Map.of("success", true, "id", id);
|
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 토글.
|
* 어드민용 hidden 토글.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.tasteby.domain.Restaurant;
|
import com.tasteby.domain.Restaurant;
|
||||||
import com.tasteby.security.AuthUtil;
|
import com.tasteby.security.AuthUtil;
|
||||||
|
import com.tasteby.dto.RestaurantUpdateDTO;
|
||||||
import com.tasteby.service.CacheService;
|
import com.tasteby.service.CacheService;
|
||||||
import com.tasteby.service.GeocodingService;
|
import com.tasteby.service.GeocodingService;
|
||||||
import com.tasteby.service.RestaurantService;
|
import com.tasteby.service.RestaurantService;
|
||||||
|
import com.tasteby.service.WebSearchService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import jakarta.annotation.PreDestroy;
|
import jakarta.annotation.PreDestroy;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -15,19 +18,10 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
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.*;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.ThreadLocalRandom;
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/restaurants")
|
@RequestMapping("/api/restaurants")
|
||||||
@@ -39,13 +33,15 @@ public class RestaurantController {
|
|||||||
private final GeocodingService geocodingService;
|
private final GeocodingService geocodingService;
|
||||||
private final CacheService cache;
|
private final CacheService cache;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final WebSearchService webSearch;
|
||||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
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.restaurantService = restaurantService;
|
||||||
this.geocodingService = geocodingService;
|
this.geocodingService = geocodingService;
|
||||||
this.cache = cache;
|
this.cache = cache;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.webSearch = webSearch;
|
||||||
}
|
}
|
||||||
|
|
||||||
// #290 — Bean 종료 시 virtual thread executor를 정리하여 리소스 누수 방지.
|
// #290 — Bean 종료 시 virtual thread executor를 정리하여 리소스 누수 방지.
|
||||||
@@ -90,30 +86,14 @@ public class RestaurantController {
|
|||||||
return r;
|
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}")
|
@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();
|
AuthUtil.requireAdmin();
|
||||||
var r = restaurantService.findById(id);
|
var r = restaurantService.findById(id);
|
||||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||||
|
|
||||||
// #332 — 입력 body를 허용 키만 통과시킨 가변 Map으로 정규화
|
// #358 — DTO → Map (null 제외). 화이트리스트는 DTO 필드 자체로 표현.
|
||||||
Map<String, Object> sanitized = new java.util.LinkedHashMap<>();
|
Map<String, Object> sanitized = dto.toFieldMap();
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-geocode if name or address changed
|
// Re-geocode if name or address changed
|
||||||
String newName = (String) sanitized.get("name");
|
String newName = (String) sanitized.get("name");
|
||||||
@@ -195,7 +175,7 @@ public class RestaurantController {
|
|||||||
@PostMapping("/bulk-tabling")
|
@PostMapping("/bulk-tabling")
|
||||||
public SseEmitter bulkTabling() {
|
public SseEmitter bulkTabling() {
|
||||||
AuthUtil.requireAdmin();
|
AuthUtil.requireAdmin();
|
||||||
SseEmitter emitter = new SseEmitter(600_000L);
|
SseEmitter emitter = new SseEmitter(10_800_000L); // 3h — 대량 백필 대응
|
||||||
|
|
||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
try {
|
try {
|
||||||
@@ -272,8 +252,11 @@ public class RestaurantController {
|
|||||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||||
String url = body.get("tabling_url");
|
String url = body.get("tabling_url");
|
||||||
// #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
|
// #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
|
||||||
if (url != null && !url.isBlank() && !url.startsWith("https://tabling.co.kr/")) {
|
// Naver/DDG 결과가 www.tabling.co.kr 형태로도 옴.
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "테이블링 URL은 https://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 : ""));
|
restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
|
||||||
cache.flush();
|
cache.flush();
|
||||||
@@ -326,7 +309,7 @@ public class RestaurantController {
|
|||||||
@PostMapping("/bulk-catchtable")
|
@PostMapping("/bulk-catchtable")
|
||||||
public SseEmitter bulkCatchtable() {
|
public SseEmitter bulkCatchtable() {
|
||||||
AuthUtil.requireAdmin();
|
AuthUtil.requireAdmin();
|
||||||
SseEmitter emitter = new SseEmitter(600_000L);
|
SseEmitter emitter = new SseEmitter(10_800_000L); // 3h — 대량 백필 대응
|
||||||
|
|
||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
try {
|
try {
|
||||||
@@ -430,95 +413,20 @@ public class RestaurantController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── DuckDuckGo HTML search helpers ─────────────────────────────────
|
// ─── 예약 사이트 URL 검색 (#357 WebSearchService: Naver primary + DDG fallback) ───
|
||||||
|
|
||||||
private static final HttpClient httpClient = HttpClient.newBuilder()
|
private List<Map<String, Object>> searchTabling(String restaurantName) {
|
||||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
return webSearch.search(
|
||||||
.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(
|
|
||||||
"site:tabling.co.kr " + restaurantName,
|
"site:tabling.co.kr " + restaurantName,
|
||||||
"tabling.co.kr/restaurant/", "tabling.co.kr/place/"
|
"tabling.co.kr/restaurant/", "tabling.co.kr/place/"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Map<String, Object>> searchCatchtable(String restaurantName) throws Exception {
|
private List<Map<String, Object>> searchCatchtable(String restaurantName) {
|
||||||
return searchDuckDuckGo(
|
// 실제 캐치테이블 URL은 /ct/shop/ 형식. 옛 /dining/ /shop/ 패턴은 매칭 실패.
|
||||||
|
return webSearch.search(
|
||||||
"site:app.catchtable.co.kr " + restaurantName,
|
"site:app.catchtable.co.kr " + restaurantName,
|
||||||
"catchtable.co.kr/dining/", "catchtable.co.kr/shop/"
|
"catchtable.co.kr/ct/shop/", "catchtable.co.kr/ct/dining/"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,9 @@ public interface RestaurantMapper {
|
|||||||
|
|
||||||
int countUnevaluatedLinks();
|
int countUnevaluatedLinks();
|
||||||
|
|
||||||
|
// #359 1단계 — google_place_id 중복 조회
|
||||||
|
List<Map<String, Object>> findDuplicatePlaceIdRows();
|
||||||
|
|
||||||
Restaurant findById(@Param("id") String id);
|
Restaurant findById(@Param("id") String id);
|
||||||
|
|
||||||
List<Map<String, Object>> findVideoLinks(@Param("restaurantId") String restaurantId,
|
List<Map<String, Object>> findVideoLinks(@Param("restaurantId") String restaurantId,
|
||||||
|
|||||||
@@ -111,6 +111,23 @@ public class RestaurantService {
|
|||||||
return mapper.countUnevaluatedLinks();
|
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) {
|
public void update(String id, Map<String, Object> fields) {
|
||||||
mapper.updateFields(id, fields);
|
mapper.updateFields(id, fields);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,11 @@ app:
|
|||||||
youtube-api-key: ${YOUTUBE_DATA_API_KEY}
|
youtube-api-key: ${YOUTUBE_DATA_API_KEY}
|
||||||
client-id: ${GOOGLE_CLIENT_ID:635551099330-2l003d3ernjmkqavd4f6s78r8r405iml.apps.googleusercontent.com}
|
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:
|
cache:
|
||||||
ttl-seconds: 600
|
ttl-seconds: 600
|
||||||
|
|
||||||
|
|||||||
@@ -353,4 +353,21 @@
|
|||||||
SELECT COUNT(*) FROM video_restaurants WHERE relevance_evaluated_at IS NULL
|
SELECT COUNT(*) FROM video_restaurants WHERE relevance_evaluated_at IS NULL
|
||||||
</select>
|
</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>
|
</mapper>
|
||||||
|
|||||||
114
docs/design/357-web-search-api/README.md
Normal file
114
docs/design/357-web-search-api/README.md
Normal 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 최신 입점 식당 미반영 가능성 — 백필 주기 후속.
|
||||||
|
- 결과 캐시(같은 식당 재호출) — 후속.
|
||||||
75
docs/design/358-restaurant-update-dto/README.md
Normal file
75
docs/design/358-restaurant-update-dto/README.md
Normal 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 — 후속.
|
||||||
47
docs/design/359a-duplicate-place-id-view/README.md
Normal file
47
docs/design/359a-duplicate-place-id-view/README.md
Normal 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).
|
||||||
@@ -9,9 +9,12 @@ type: Opaque
|
|||||||
stringData:
|
stringData:
|
||||||
ORACLE_USER: "<oracle-username>"
|
ORACLE_USER: "<oracle-username>"
|
||||||
ORACLE_PASSWORD: "<oracle-password>"
|
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>"
|
JWT_SECRET: "<jwt-secret>"
|
||||||
OCI_COMPARTMENT_ID: "<oci-compartment-id>"
|
OCI_COMPARTMENT_ID: "<oci-compartment-id>"
|
||||||
OCI_CHAT_MODEL_ID: "<oci-chat-model-id>"
|
OCI_CHAT_MODEL_ID: "<oci-chat-model-id>"
|
||||||
GOOGLE_MAPS_API_KEY: "<google-maps-api-key>"
|
GOOGLE_MAPS_API_KEY: "<google-maps-api-key>"
|
||||||
YOUTUBE_DATA_API_KEY: "<youtube-data-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>"
|
||||||
|
|||||||
Reference in New Issue
Block a user