Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7789671fbc | ||
|
|
c5b0216a37 | ||
|
|
40e448fe95 | ||
|
|
8a21646031 |
23
CHANGELOG.md
23
CHANGELOG.md
@@ -4,8 +4,31 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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
|
## 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)
|
### 🐛 #357 후속 — tabling-url validation에 www. 호스트 허용 (v0.1.47)
|
||||||
- Naver/DDG 결과가 `https://www.tabling.co.kr/...` 형태인데 #290 validation은 `tabling.co.kr/`만 허용 → 단건 매핑 PUT 거부
|
- Naver/DDG 결과가 `https://www.tabling.co.kr/...` 형태인데 #290 validation은 `tabling.co.kr/`만 허용 → 단건 매핑 PUT 거부
|
||||||
- bulk-tabling SSE는 validation 없이 service.update 직접 호출이라 통과 → 단일/벌크 불일치
|
- bulk-tabling SSE는 validation 없이 service.update 직접 호출이라 통과 → 단일/벌크 불일치
|
||||||
|
|||||||
@@ -175,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 {
|
||||||
@@ -309,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 {
|
||||||
@@ -423,9 +423,10 @@ public class RestaurantController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<Map<String, Object>> searchCatchtable(String restaurantName) {
|
private List<Map<String, Object>> searchCatchtable(String restaurantName) {
|
||||||
|
// 실제 캐치테이블 URL은 /ct/shop/ 형식. 옛 /dining/ /shop/ 패턴은 매칭 실패.
|
||||||
return webSearch.search(
|
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/"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import java.net.http.HttpClient;
|
|||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
@@ -30,8 +31,10 @@ public class WebSearchService {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(WebSearchService.class);
|
private static final Logger log = LoggerFactory.getLogger(WebSearchService.class);
|
||||||
private static final int MAX_RESULTS = 5;
|
private static final int MAX_RESULTS = 5;
|
||||||
|
|
||||||
|
private static final Duration REQ_TIMEOUT = Duration.ofSeconds(15);
|
||||||
private static final HttpClient HTTP = HttpClient.newBuilder()
|
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.connectTimeout(Duration.ofSeconds(5))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
private static final Pattern DDG_RESULT = Pattern.compile(
|
private static final Pattern DDG_RESULT = Pattern.compile(
|
||||||
@@ -74,6 +77,7 @@ public class WebSearchService {
|
|||||||
String url = "https://openapi.naver.com/v1/search/webkr.json?query=" + encoded + "&display=30";
|
String url = "https://openapi.naver.com/v1/search/webkr.json?query=" + encoded + "&display=30";
|
||||||
HttpRequest req = HttpRequest.newBuilder()
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(url))
|
.uri(URI.create(url))
|
||||||
|
.timeout(REQ_TIMEOUT)
|
||||||
.header("X-Naver-Client-Id", naverClientId)
|
.header("X-Naver-Client-Id", naverClientId)
|
||||||
.header("X-Naver-Client-Secret", naverClientSecret)
|
.header("X-Naver-Client-Secret", naverClientSecret)
|
||||||
.GET()
|
.GET()
|
||||||
@@ -104,6 +108,7 @@ public class WebSearchService {
|
|||||||
String url = "https://html.duckduckgo.com/html/?q=" + encoded;
|
String url = "https://html.duckduckgo.com/html/?q=" + encoded;
|
||||||
HttpRequest req = HttpRequest.newBuilder()
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(url))
|
.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("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", "text/html,application/xhtml+xml")
|
||||||
.header("Accept-Language", "ko-KR,ko;q=0.9")
|
.header("Accept-Language", "ko-KR,ko;q=0.9")
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ function buildSearchQuery(r: Restaurant): string {
|
|||||||
return r.name;
|
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({
|
export default function RestaurantDetail({
|
||||||
restaurant,
|
restaurant,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -138,22 +147,33 @@ export default function RestaurantDetail({
|
|||||||
)}
|
)}
|
||||||
{restaurant.google_place_id && (
|
{restaurant.google_place_id && (
|
||||||
<p className="flex gap-3">
|
<p className="flex gap-3">
|
||||||
<a
|
{isKoreaRestaurant(restaurant) ? (
|
||||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(buildSearchQuery(restaurant))}`}
|
<>
|
||||||
target="_blank"
|
<a
|
||||||
rel="noopener noreferrer"
|
href={`https://map.naver.com/p/search/${encodeURIComponent(buildSearchQuery(restaurant))}`}
|
||||||
className="text-brand-600 dark:text-brand-400 hover:underline text-xs"
|
target="_blank"
|
||||||
>
|
rel="noopener noreferrer"
|
||||||
Google Maps에서 보기
|
className="text-green-600 dark:text-green-400 hover:underline text-xs"
|
||||||
</a>
|
>
|
||||||
{(!restaurant.region || restaurant.region.split("|")[0] === "한국") && (
|
네이버 지도에서 보기
|
||||||
|
</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
|
<a
|
||||||
href={`https://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`}
|
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(buildSearchQuery(restaurant))}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-green-600 dark:text-green-400 hover:underline text-xs"
|
className="text-brand-600 dark:text-brand-400 hover:underline text-xs"
|
||||||
>
|
>
|
||||||
네이버 지도에서 보기
|
Google Maps에서 보기
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user