[Developer] #533 언급 적은 식당 제외 필터 (min_mentions + video_count)
- backend: findAll 응답에 video_count(strong/unknown 링크수) + min_mentions 서버필터 - Restaurant.videoCount, RestaurantMapper(xml/iface), Service, Controller(+cache key) - frontend: Restaurant.video_count 타입 + 데스크톱 툴바 "언급"(2/3/5회+) 클라 필터 - 검증: min_mentions=2→116, =3→36 (위반 0). 빌드/tsc 통과 - 설계서 docs/design/533-min-mentions-filter/README.md Refs #533 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
10
CHANGELOG.md
10
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`/더미(`나라`) 제거 후 ` › ` 계층 조인
|
||||
|
||||
@@ -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<List<Restaurant>>() {});
|
||||
} 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;
|
||||
}
|
||||
|
||||
@@ -39,4 +39,7 @@ public class Restaurant {
|
||||
// Transient enrichment fields
|
||||
private List<String> channels;
|
||||
private List<String> foodsMentioned;
|
||||
|
||||
// #533 — 의미있는(strong/unknown) 영상 언급 수
|
||||
private Integer videoCount;
|
||||
}
|
||||
|
||||
@@ -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 표시 갱신
|
||||
|
||||
@@ -21,11 +21,13 @@ public class RestaurantService {
|
||||
}
|
||||
|
||||
public List<Restaurant> 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<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel, boolean includeHidden) {
|
||||
List<Restaurant> restaurants = mapper.findAll(limit, offset, cuisine, region, channel, includeHidden);
|
||||
// #533 — minMentions: 의미있는 언급 수가 N 미만인 식당 제외 (null/0이면 미적용)
|
||||
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel,
|
||||
Integer minMentions, boolean includeHidden) {
|
||||
List<Restaurant> restaurants = mapper.findAll(limit, offset, cuisine, region, channel, minMentions, includeHidden);
|
||||
enrichRestaurants(restaurants);
|
||||
return restaurants;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<result property="hidden" column="hidden" javaType="java.lang.Boolean"/>
|
||||
<result property="hiddenReason" column="hidden_reason"/>
|
||||
<result property="verifiedAt" column="verified_at"/>
|
||||
<result property="videoCount" column="video_count"/>
|
||||
</resultMap>
|
||||
|
||||
<!-- ===== Queries ===== -->
|
||||
@@ -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
|
||||
<if test="channel != null and channel != ''">
|
||||
JOIN video_restaurants vr_f ON vr_f.restaurant_id = r.id
|
||||
@@ -55,6 +58,10 @@
|
||||
<if test="channel != null and channel != ''">
|
||||
AND c_f.channel_name = #{channel}
|
||||
</if>
|
||||
<if test="minMentions != null and minMentions > 0">
|
||||
AND (SELECT COUNT(*) FROM video_restaurants vrc
|
||||
WHERE vrc.restaurant_id = r.id AND vrc.relevance IN ('strong','unknown')) >= #{minMentions}
|
||||
</if>
|
||||
</where>
|
||||
ORDER BY r.updated_at DESC
|
||||
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
|
||||
|
||||
44
docs/design/533-min-mentions-filter/README.md
Normal file
44
docs/design/533-min-mentions-filter/README.md
Normal file
@@ -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: `<result property="videoCount" column="video_count"/>`
|
||||
- `findAll` SELECT에 스칼라 서브쿼리 추가:
|
||||
`(SELECT COUNT(*) FROM video_restaurants vrc WHERE vrc.restaurant_id = r.id AND vrc.relevance IN ('strong','unknown')) AS video_count`
|
||||
- `findAll` `<where>`에 optional 필터:
|
||||
`<if test="minMentions != null and minMentions > 0"> AND (위 서브쿼리) >= #{minMentions} </if>`
|
||||
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` 포함 확인.
|
||||
- 프론트 토글로 목록/지도가 줄어드는지.
|
||||
@@ -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<FilterOption[]>(() => {
|
||||
@@ -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() {
|
||||
<option key={g.label} value={g.label}>{g.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{(cuisineFilter || priceFilter) && (
|
||||
<div className="w-px h-3 bg-gray-200 dark:bg-gray-700" />
|
||||
<select
|
||||
value={minMentions}
|
||||
onChange={(e) => { setMinMentions(Number(e.target.value)); if (Number(e.target.value)) setBoundsFilterOn(false); }}
|
||||
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
|
||||
minMentions > 0 ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
title="영상에서 충분히 언급된(관련도 높은) 식당만 보기"
|
||||
>
|
||||
<option value={0}>언급</option>
|
||||
<option value={2}>2회+</option>
|
||||
<option value={3}>3회+</option>
|
||||
<option value={5}>5회+</option>
|
||||
</select>
|
||||
{(cuisineFilter || priceFilter || minMentions > 0) && (
|
||||
<button
|
||||
onClick={() => { setCuisineFilter(""); setPriceFilter(""); }}
|
||||
onClick={() => { setCuisineFilter(""); setPriceFilter(""); setMinMentions(0); }}
|
||||
className="p-1.5 -mr-1 text-gray-400 hover:text-brand-500 transition-colors touch-manipulation"
|
||||
title="음식 필터 초기화"
|
||||
>
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface Restaurant {
|
||||
website: string | null;
|
||||
channels?: string[];
|
||||
foods_mentioned?: string[];
|
||||
// #533 — 의미있는(strong/unknown) 영상 언급 수
|
||||
video_count?: number | null;
|
||||
// #322 LLM 검증
|
||||
hidden?: boolean;
|
||||
hidden_reason?: string | null;
|
||||
@@ -168,6 +170,7 @@ export const api = {
|
||||
channel?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
min_mentions?: number;
|
||||
}) {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.cuisine) sp.set("cuisine", params.cuisine);
|
||||
@@ -175,6 +178,7 @@ export const api = {
|
||||
if (params?.channel) sp.set("channel", params.channel);
|
||||
if (params?.limit) sp.set("limit", String(params.limit));
|
||||
if (params?.offset) sp.set("offset", String(params.offset));
|
||||
if (params?.min_mentions) sp.set("min_mentions", String(params.min_mentions));
|
||||
const qs = sp.toString();
|
||||
return fetchApi<Restaurant[]>(`/api/restaurants${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user