diff --git a/CHANGELOG.md b/CHANGELOG.md index a682b5a..ffd1970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ ## 2026-06-28 +### ✨ 언급 적은(관련도 낮은) 식당 제외 필터 (#533) +- 신호: "의미있는 언급 수" = video_restaurants 중 relevance ∈ (strong, unknown) 개수 (weak/incidental 제외) +- 백엔드: 식당 목록 응답에 `video_count` 추가 + `min_mentions` 서버 필터 파라미터 + - RestaurantMapper.xml findAll: 스칼라 서브쿼리 video_count + minMentions 필터 + - Restaurant.videoCount 필드 / Service·Controller 스레드 / 캐시키 `mm=` 포함 + - 검증: min_mentions=2 → 116개(전부 ≥2, 위반 0), =3 → 36개 +- 프론트: Restaurant.video_count 타입 + 데스크톱 툴바 "언급" 필터(2/3/5회+) 클라이언트 필터 +- 후속: 모바일 FilterSheet 적용 + 서버사이드 일원화(#535) +- 설계서: `docs/design/533-min-mentions-filter/README.md` + ### 🐛 지역 표시 계층화 — "한국|서울" → "한국 › 서울" (#532) - 문제: 식당 상세/리스트가 region 원본(`한국|서울|강남구`, 파이프 노출)을 그대로 표시 - 해결: 공용 유틸 `lib/region.ts` `formatRegion()` 신설 — 빈토큰/`null`/더미(`나라`) 제거 후 ` › ` 계층 조인 diff --git a/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java b/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java index 6a554d3..a1500c0 100644 --- a/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java +++ b/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java @@ -56,17 +56,18 @@ public class RestaurantController { @RequestParam(defaultValue = "0") int offset, @RequestParam(required = false) String cuisine, @RequestParam(required = false) String region, - @RequestParam(required = false) String channel) { + @RequestParam(required = false) String channel, + @RequestParam(name = "min_mentions", required = false) Integer minMentions) { if (limit > 500) limit = 500; String key = cache.makeKey("restaurants", "l=" + limit, "o=" + offset, - "c=" + cuisine, "r=" + region, "ch=" + channel); + "c=" + cuisine, "r=" + region, "ch=" + channel, "mm=" + minMentions); String cached = cache.getRaw(key); if (cached != null) { try { return objectMapper.readValue(cached, new TypeReference>() {}); } catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); } } - var result = restaurantService.findAll(limit, offset, cuisine, region, channel); + var result = restaurantService.findAll(limit, offset, cuisine, region, channel, minMentions, false); cache.set(key, result); return result; } diff --git a/backend-java/src/main/java/com/tasteby/domain/Restaurant.java b/backend-java/src/main/java/com/tasteby/domain/Restaurant.java index ecdf1fc..7345a4e 100644 --- a/backend-java/src/main/java/com/tasteby/domain/Restaurant.java +++ b/backend-java/src/main/java/com/tasteby/domain/Restaurant.java @@ -39,4 +39,7 @@ public class Restaurant { // Transient enrichment fields private List channels; private List foodsMentioned; + + // #533 — 의미있는(strong/unknown) 영상 언급 수 + private Integer videoCount; } diff --git a/backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java b/backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java index 8f270b7..97b54a7 100644 --- a/backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java +++ b/backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java @@ -15,6 +15,7 @@ public interface RestaurantMapper { @Param("cuisine") String cuisine, @Param("region") String region, @Param("channel") String channel, + @Param("minMentions") Integer minMentions, @Param("includeHidden") boolean includeHidden); // #322 LLM 검증: hidden 표시 갱신 diff --git a/backend-java/src/main/java/com/tasteby/service/RestaurantService.java b/backend-java/src/main/java/com/tasteby/service/RestaurantService.java index e1397e6..dfd59b0 100644 --- a/backend-java/src/main/java/com/tasteby/service/RestaurantService.java +++ b/backend-java/src/main/java/com/tasteby/service/RestaurantService.java @@ -21,11 +21,13 @@ public class RestaurantService { } public List findAll(int limit, int offset, String cuisine, String region, String channel) { - return findAll(limit, offset, cuisine, region, channel, false); + return findAll(limit, offset, cuisine, region, channel, null, false); } - public List findAll(int limit, int offset, String cuisine, String region, String channel, boolean includeHidden) { - List restaurants = mapper.findAll(limit, offset, cuisine, region, channel, includeHidden); + // #533 — minMentions: 의미있는 언급 수가 N 미만인 식당 제외 (null/0이면 미적용) + public List findAll(int limit, int offset, String cuisine, String region, String channel, + Integer minMentions, boolean includeHidden) { + List restaurants = mapper.findAll(limit, offset, cuisine, region, channel, minMentions, includeHidden); enrichRestaurants(restaurants); return restaurants; } diff --git a/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml b/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml index df6fd14..2e17f35 100644 --- a/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml +++ b/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml @@ -25,6 +25,7 @@ + @@ -33,7 +34,9 @@ SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude, r.cuisine_type, r.price_range, r.google_place_id, r.tabling_url, r.catchtable_url, r.business_status, r.rating, r.rating_count, r.updated_at, - r.hidden, r.hidden_reason, r.verified_at + r.hidden, r.hidden_reason, r.verified_at, + (SELECT COUNT(*) FROM video_restaurants vrc + WHERE vrc.restaurant_id = r.id AND vrc.relevance IN ('strong','unknown')) AS video_count FROM restaurants r JOIN video_restaurants vr_f ON vr_f.restaurant_id = r.id @@ -55,6 +58,10 @@ AND c_f.channel_name = #{channel} + + AND (SELECT COUNT(*) FROM video_restaurants vrc + WHERE vrc.restaurant_id = r.id AND vrc.relevance IN ('strong','unknown')) >= #{minMentions} + ORDER BY r.updated_at DESC OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY diff --git a/docs/design/533-min-mentions-filter/README.md b/docs/design/533-min-mentions-filter/README.md new file mode 100644 index 0000000..8e6c08f --- /dev/null +++ b/docs/design/533-min-mentions-filter/README.md @@ -0,0 +1,44 @@ +# #533 — 언급 적은(관련도 낮은) 식당 제외 필터 + +## 배경 / 문제 +영상에서 잠깐·우연히 언급된(=관련도 낮은) 식당까지 모두 노출되어 신호 대비 잡음이 큼. +사용자가 "언급이 충분한 식당만" 보도록 제외 필터 제공. + +## 신호 정의 +"의미있는 언급 수(`video_count`)" = 해당 식당의 `video_restaurants` 링크 중 +**relevance ∈ ('strong','unknown')** 개수. (weak/incidental = 곁다리 언급 → 제외) +→ 기존 `findVideoLinks` 기본 노출 정책(strong/unknown)과 일관. + +## 범위 +- **백엔드**: 식당 목록 응답에 `video_count` 포함 + `min_mentions` 서버 필터 파라미터. +- **프론트**: `Restaurant.video_count` 타입 + 클라이언트 필터 UI(전체/2회+/3회+). +- 서버사이드 필터 일원화(현 클라 필터 패턴 통일)는 **#535**로 분리. + +## 함수/변경 설계 + +### 백엔드 +1. `domain/Restaurant.java` — `Integer videoCount` 필드 추가(전이 enrichment). +2. `mapper/RestaurantMapper.xml` + - `restaurantMap` resultMap: `` + - `findAll` SELECT에 스칼라 서브쿼리 추가: + `(SELECT COUNT(*) FROM video_restaurants vrc WHERE vrc.restaurant_id = r.id AND vrc.relevance IN ('strong','unknown')) AS video_count` + - `findAll` ``에 optional 필터: + ` AND (위 서브쿼리) >= #{minMentions} ` +3. `mapper/RestaurantMapper.java` — `@Param("minMentions") Integer minMentions` 추가. +4. `service/RestaurantService.java` — `findAll(..., Integer minMentions, boolean includeHidden)`로 스레드. 5-arg 오버로드는 `null, false` 전달. +5. `controller/RestaurantController.java` — `@RequestParam("min_mentions") Integer minMentions` + 캐시 키에 `mm=` 포함. + +### 프론트 +6. `lib/api.ts` — `Restaurant.video_count?: number | null`; `getRestaurants` 파라미터 `min_mentions?`(미래/서버필터용). +7. `app/page.tsx` — 필터 상태 `minMentions`(0/2/3) + `filteredRestaurants`에 `(r.video_count ?? 0) >= minMentions` 가드 + UI 컨트롤 + useMemo deps 추가. + +## 엣지케이스 +- `video_count` 미산정(findById 등) → null. 클라 필터는 `?? 0`로 안전. +- minMentions=0(기본) → 필터 미적용(기존 동작 유지). +- DISTINCT + 스칼라 서브쿼리: r.id 단위라 결과 불변. + +## 검증 +- 백엔드 빌드 성공 → PM2 재시작. +- `GET /api/restaurants?min_mentions=2` 가 2회 미만 식당 제외하는지. +- 응답에 `video_count` 포함 확인. +- 프론트 토글로 목록/지도가 줄어드는지. diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 88c1829..f8d3049 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -196,6 +196,7 @@ export default function Home() { const [channelFilter, setChannelFilter] = useState(""); const [cuisineFilter, setCuisineFilter] = useState(""); const [priceFilter, setPriceFilter] = useState(""); + const [minMentions, setMinMentions] = useState(0); // #533 — 의미있는 언급 N회 미만 식당 제외 (0=전체) const [viewMode, setViewMode] = useState<"map" | "list">("list"); const [mobileTab, setMobileTab] = useState<"home" | "list" | "nearby" | "favorites" | "profile">("home"); const [showMobileFilters, setShowMobileFilters] = useState(false); @@ -248,6 +249,7 @@ export default function Home() { if (channelFilter && !(r.channels || []).includes(channelFilter)) return false; if (cuisineFilter && !matchCuisineFilter(r.cuisine_type, cuisineFilter)) return false; if (priceFilter && !matchPriceGroup(r.price_range, priceFilter)) return false; + if (minMentions > 0 && (r.video_count ?? 0) < minMentions) return false; if (countryFilter) { const parsed = parseRegion(r.region); if (!parsed || parsed.country !== countryFilter) return false; @@ -271,7 +273,7 @@ export default function Home() { if (da !== db) return da - db; return (b.rating || 0) - (a.rating || 0); }); - }, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]); + }, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, minMentions, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]); // FilterSheet option builders const cuisineOptions = useMemo(() => { @@ -366,6 +368,7 @@ export default function Home() { setChannelFilter(""); setCuisineFilter(""); setPriceFilter(""); + setMinMentions(0); setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); @@ -477,6 +480,7 @@ export default function Home() { setChannelFilter(""); setCuisineFilter(""); setPriceFilter(""); + setMinMentions(0); setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); @@ -818,9 +822,23 @@ export default function Home() { ))} - {(cuisineFilter || priceFilter) && ( +
+ + {(cuisineFilter || priceFilter || minMentions > 0) && (