Compare commits

...

6 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
9 changed files with 175 additions and 17 deletions

View File

@@ -4,8 +4,44 @@
---
## 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

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

@@ -175,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 {
@@ -252,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();
@@ -306,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 {
@@ -420,9 +423,10 @@ public class RestaurantController {
}
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

@@ -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

@@ -14,6 +14,7 @@ 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;
@@ -30,8 +31,10 @@ 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(
@@ -74,6 +77,7 @@ public class WebSearchService {
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()
@@ -104,6 +108,7 @@ public class WebSearchService {
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")

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,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,22 +147,33 @@ export default function RestaurantDetail({
)}
{restaurant.google_place_id && (
<p className="flex gap-3">
<a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(buildSearchQuery(restaurant))}`}
target="_blank"
rel="noopener noreferrer"
className="text-brand-600 dark:text-brand-400 hover:underline text-xs"
>
Google Maps에서
</a>
{(!restaurant.region || restaurant.region.split("|")[0] === "한국") && (
{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://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`}
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(buildSearchQuery(restaurant))}`}
target="_blank"
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>
)}
</p>