Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7789671fbc | ||
|
|
c5b0216a37 | ||
|
|
40e448fe95 | ||
|
|
8a21646031 | ||
|
|
52090057de | ||
|
|
d73947444f | ||
|
|
c1050f3abd |
45
CHANGELOG.md
45
CHANGELOG.md
@@ -4,8 +4,53 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
- 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)
|
### 🔎 #357 DDG → Naver Search 정식 API + DDG 폴백 (v0.1.44)
|
||||||
- WebSearchService 신규 (Naver webkr.json 우선, 키 미설정/실패 시 DDG 폴백)
|
- WebSearchService 신규 (Naver webkr.json 우선, 키 미설정/실패 시 DDG 폴백)
|
||||||
- RestaurantController.searchTabling/searchCatchtable 내부 호출 교체, DDG 인라인 80줄 제거
|
- RestaurantController.searchTabling/searchCatchtable 내부 호출 교체, DDG 인라인 80줄 제거
|
||||||
|
|||||||
@@ -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,10 +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 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;
|
||||||
@@ -84,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");
|
||||||
@@ -189,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 {
|
||||||
@@ -266,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();
|
||||||
@@ -320,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 {
|
||||||
@@ -434,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/"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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).
|
||||||
@@ -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,6 +147,26 @@ export default function RestaurantDetail({
|
|||||||
)}
|
)}
|
||||||
{restaurant.google_place_id && (
|
{restaurant.google_place_id && (
|
||||||
<p className="flex gap-3">
|
<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
|
<a
|
||||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(buildSearchQuery(restaurant))}`}
|
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(buildSearchQuery(restaurant))}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -146,15 +175,6 @@ export default function RestaurantDetail({
|
|||||||
>
|
>
|
||||||
Google Maps에서 보기
|
Google Maps에서 보기
|
||||||
</a>
|
</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>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user