Compare commits
22 Commits
v0.1.13
...
dcebb9f06f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcebb9f06f | ||
|
|
bff3dcc200 | ||
|
|
ea8db4bef3 | ||
|
|
ed076411ed | ||
|
|
865cd86aff | ||
|
|
c6428e5d5f | ||
|
|
5579c5b00f | ||
|
|
4b02293046 | ||
|
|
eb1eaa91a6 | ||
|
|
9c2dc9f43a | ||
|
|
7779d5ddfd | ||
|
|
6ea82a5561 | ||
|
|
04c54d1b1a | ||
|
|
4407f2d67d | ||
|
|
7fa623d22d | ||
|
|
d2e78b0363 | ||
|
|
d3cd1b5d5f | ||
|
|
51dcacc728 | ||
|
|
dc8a8e9b4c | ||
|
|
43fd931824 | ||
|
|
2d41f22b83 | ||
|
|
2a6d307260 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,3 +18,5 @@ k8s/secrets.yaml
|
|||||||
# OS / misc
|
# OS / misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
backend/cookies.txt
|
backend/cookies.txt
|
||||||
|
backend-java/cookies.txt
|
||||||
|
**/cookies.txt
|
||||||
|
|||||||
112
CHANGELOG.md
112
CHANGELOG.md
@@ -6,6 +6,118 @@
|
|||||||
|
|
||||||
## 2026-06-15
|
## 2026-06-15
|
||||||
|
|
||||||
|
### ⭐ P4-4 별점 공통화 + 로그인 모달 접근성 (v0.1.23)
|
||||||
|
- #281: 공통 Stars 컴포넌트 (0.5단위 절반 채우기), StarSelector role=radiogroup + 44px + 반쪽 별 ⯨, try/catch + alert
|
||||||
|
- #283: LoginMenu에 useEscapeKey/useFocusTrap/useBodyScrollLock 훅 적용, role=dialog/aria-modal/aria-labelledby, onError 인라인 alert
|
||||||
|
- MyReviewsList: Math.round → Stars (0.5단위 정확 렌더)
|
||||||
|
- 후속 분리: #343(next/image, ARIA Tabs, 테스트), #344(z-index 토큰, i18n)
|
||||||
|
- Refs: #281 #283 (close)
|
||||||
|
|
||||||
|
### 🔐 P4-3 인증 메시지 + 지도 접근성 (v0.1.22)
|
||||||
|
- #266: Google verifier 실패 메시지 고정 + log.warn (정보 누출 차단)
|
||||||
|
- #278: boundsTimerRef cleanup, '내 위치' 44px + aria-label, dead code 제거
|
||||||
|
- #277: 결함 모두 후속(#338) — deep health/version/테스트는 별도
|
||||||
|
- 후속 분리: #338(deep health), #339(브랜드 토큰화/마커 ARIA), #340(다중 audience)
|
||||||
|
- Refs: #266 #277 #278 (close)
|
||||||
|
|
||||||
|
### ⚙️ P4-2 데몬/캐시/통계 결함 (v0.1.21)
|
||||||
|
- #275: updateConfig 가드(1+ 정수), Scheduler try-finally updateLastX, GET config admin-only
|
||||||
|
- #276: ping try-with-resources + ConnectionFactory null 가드, makeKey null 가드
|
||||||
|
- #274: SiteVisitStats int → long, recordVisit DataIntegrityViolationException 1회 재시도
|
||||||
|
- 후속 분리: #335 (분산락), #336 (SCAN/자동복구), #337 (봇/레이트리밋)
|
||||||
|
- Refs: #275 #276 #274 (close)
|
||||||
|
|
||||||
|
### 🧱 P4-1 백엔드 CRUD 결함 (v0.1.20)
|
||||||
|
- #294: MemoService/ReviewService 동시성 DuplicateKeyException 가드, rating 0~5 검증, getAvgRating NVL
|
||||||
|
- #295: 유니크 충돌 typed exception, channel_id "UC..." 형식 명시 분기, findByChannelId 컬럼 보완, body null 가드
|
||||||
|
- #290: @PreDestroy executor shutdown, 캐시 silent → log.warn + cache.del, tabling/catchtable URL 스킴 화이트리스트
|
||||||
|
- 후속 분리: #332(#290), #333(#295), #334(#294) — DTO/DDG/세분화/테스트
|
||||||
|
- Refs: #290 #294 #295 (close)
|
||||||
|
|
||||||
|
### 🔍 #293 검색/벡터 결함 7건 (v0.1.19)
|
||||||
|
- SearchController: q 빈값 400 가드 (`%%` 응답 폭발 차단)
|
||||||
|
- SearchService: LIKE 와일드카드 escape (%, _, \), hybrid 모드에서 sem 결과에도 채널 부착
|
||||||
|
- SearchService: ObjectMapper/TypeReference static 재사용, 알 수 없는 mode warn 로그
|
||||||
|
- SearchService: maxDistance를 @Value("${app.search.max-distance:0.57}") 외부화 (env SEARCH_MAX_DISTANCE)
|
||||||
|
- SearchMapper.xml: LIKE 절에 ESCAPE '\' 추가
|
||||||
|
- VectorService: embeddings null/empty 가드 (NPE 차단)
|
||||||
|
- 후속 분리: #331 (batch insert + 테스트)
|
||||||
|
- Refs: #293 (close)
|
||||||
|
|
||||||
|
### 🛠 #304+#323 어드민 LLM 검증 UI + 공통 유틸 (v0.1.18)
|
||||||
|
- 신규 frontend/src/lib/admin-utils.ts:
|
||||||
|
- getAdminToken / authHeaders / consumeSseStream
|
||||||
|
- api.ts: Restaurant 타입에 hidden/hidden_reason/verified_at + verify/setRestaurantHidden API 4개
|
||||||
|
- RestaurantsPanel:
|
||||||
|
- 헤더: "미검증 N건 + LLM 검증" 버튼
|
||||||
|
- 테이블: 검증 컬럼 (숨김/OK/미검증 배지 + 클릭으로 토글)
|
||||||
|
- colSpan 7로 수정
|
||||||
|
- 후속 분리: #329 (admin 전체 파일 분리 + localStorage/SSE 11+곳 통일)
|
||||||
|
- Refs: #304 #323 #322 (close)
|
||||||
|
|
||||||
|
### 🔧 #291+#292 백엔드 결함 일괄 수정 (v0.1.17)
|
||||||
|
- ExtractorService: transcript null/blank 가드 (NPE 방지)
|
||||||
|
- PipelineService.processExtract: 진입 시 status='processing' 명시 전이 (SSE/사용자 가시성)
|
||||||
|
- PipelineService: geocode 실패 시 좌표/place_id/주소 컬럼을 data에 put하지 않아 upsert COALESCE 보존 의도 명확화
|
||||||
|
- GeocodingService.parseRegionFromAddress: 빈 토큰을 region 문자열에서 제거 ('한국||구' 깨짐 방지)
|
||||||
|
- VideoService.saveVideosBatch: @Transactional 추가 → batch insert 원자성
|
||||||
|
- .gitignore: backend-java/cookies.txt 및 **/cookies.txt
|
||||||
|
- 후속 분리: #325 (#291 잔여 MINOR), #326 (parseJson 최적화 + #292 MINOR)
|
||||||
|
- Refs: #291 #292 (close)
|
||||||
|
|
||||||
|
### 🧹 #322 LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김 (v0.1.16)
|
||||||
|
- DB 마이그레이션: restaurants에 hidden(NUMBER(1)), hidden_reason(VARCHAR2(120)), verified_at(TIMESTAMP) + idx_restaurants_hidden
|
||||||
|
- 도메인/Mapper/Service 확장: includeHidden 옵션, updateVerification, findUnverified 등
|
||||||
|
- 신규 RestaurantVerifyService:
|
||||||
|
- verifyAsync (신규 등록 자동 검증)
|
||||||
|
- verifyAll (백필, 식당당 200ms sleep)
|
||||||
|
- parseVerifyResponse (안전 기본값: 파싱 실패 시 valid=true → hidden 유지)
|
||||||
|
- PipelineService.processExtract 끝에 verifyAsync(restId) 자동 호출
|
||||||
|
- AdminRestaurantController 신규 (requireAdmin):
|
||||||
|
- GET /api/admin/restaurants/verify/pending
|
||||||
|
- POST /api/admin/restaurants/verify/all?batchSize=10
|
||||||
|
- POST /api/admin/restaurants/{id}/verify
|
||||||
|
- PATCH /api/admin/restaurants/{id}/hidden
|
||||||
|
- 어드민 UI는 후속 #323으로 분리
|
||||||
|
- Refs: #322 (close)
|
||||||
|
|
||||||
|
### 📺 #291 publishedAfter 페이징 조기 종료 버그 (v0.1.15) + dev/prod 데몬 분리
|
||||||
|
- YouTubeService.fetchChannelVideos: stopPaging 플래그로 조기 종료 정확화 → 백필 효율 + YouTube API quota 절약
|
||||||
|
- DaemonScheduler에 app.daemon.enabled (env DAEMON_ENABLED) 플래그
|
||||||
|
- dev/prod가 같은 Oracle ATP를 공유하는 환경에서 dev DAEMON_ENABLED=false로 중복 폴링 차단
|
||||||
|
- Refs: #291 #275 #321
|
||||||
|
|
||||||
|
### ♿ #301+#302 모달 접근성 + race condition + 필터 상태 동기화 (v0.1.14)
|
||||||
|
- 공통 훅 `frontend/src/lib/hooks/useModalA11y.ts` 신규 (useEscapeKey, useFocusTrap, useBodyScrollLock)
|
||||||
|
- BottomSheet/FilterSheet: role='dialog', aria-modal, aria-label/labelledby, ESC 닫기, focus trap
|
||||||
|
- RestaurantDetail: useEffect cancelled 플래그로 restaurant.id 변경 시 race condition 차단
|
||||||
|
- page.tsx: `exitSearchMode` 헬퍼 → 검색결과 모드에서 필터 변경 시 자동 검색 모드 해제 + 원본 재로드
|
||||||
|
- 후속 분리: #319 (BottomSheet 매직넘버/UX), #320 (필터 정밀도/접근성/테스트)
|
||||||
|
- Refs: #301 #302 (close)
|
||||||
|
|
||||||
|
### 🔧 #316 — backend resource request 재산정 + RollingUpdate 정책 복귀
|
||||||
|
- **변경 전**: cpu 500m/1, mem 768Mi/1536Mi, strategy maxSurge=0/maxUnavailable=1 (임시 패치)
|
||||||
|
- **변경 후**: cpu 300m/800m, mem 512Mi/1024Mi, strategy 25%/25% (기본 복귀)
|
||||||
|
- **근거**: 실측 idle 0.7% CPU, RSS ~305 MB. peak 30-40% 추정 안에서 안전.
|
||||||
|
- **검증**: rollout 후 노드 잔여 330m → 다음 배포 시 두 Pod 공존 가능, 무중단 RollingUpdate 회복.
|
||||||
|
- **다운타임**: 이번 1회 ~25초 (구 Pod 500m 점유 해제 위해 강제 종료). 다음 배포부터 0초.
|
||||||
|
- **설계서**: `docs/design/316-backend-resource-rightsize/README.md` (Approved).
|
||||||
|
- Refs: #316 (close)
|
||||||
|
|
||||||
|
### 🏗 OKE 인프라 — 노드 다운사이징 + LB 정리
|
||||||
|
- **Orphan Classic LB 삭제**: 132.226.175.247 (100Mbps shape, OKEclusterName 태그만 남고 DNS/Service 참조 없음) → 비용 절감
|
||||||
|
- **노드풀 교체 (블루-그린)**: `pool1` (2 노드 × 2 OCPU / 8 GB) → `pool2` (2 노드 × 1 OCPU / 6 GB)
|
||||||
|
- 사유: ARM64 Always Free 쿼터 변경 (4 OCPU/24 GB → 2 OCPU/12 GB)
|
||||||
|
- 절차: 새 노드풀 생성 → 기존 노드 cordon + drain → 기존 노드풀 삭제 → 무중단 확인
|
||||||
|
- **backend Deployment strategy 임시 패치**: `maxSurge: 25% → 0`, `maxUnavailable: 25% → 1`
|
||||||
|
- 노드당 1 OCPU 환경에서 backend(500m 요청) 두 Pod 공존 불가 → rollingUpdate 데드락 회피
|
||||||
|
- **⚠️ 다음 배포 시 ~30초 다운타임** 발생. 후속 이슈에서 resource request 재산정 권고.
|
||||||
|
|
||||||
|
### 🚀 운영 배포 v0.1.13
|
||||||
|
- 보안 핫픽스 #267 배포 (백엔드만)
|
||||||
|
- OCIR push + kubectl rolling update + git tag v0.1.13 완료
|
||||||
|
- 검증: `Anonymous /api/admin/users → 403`, `Bad-token → 403`, `정상 동작 영향 없음`
|
||||||
|
|
||||||
### 🔴 보안 핫픽스 #267 — AdminUserController GET 4종 권한 우회
|
### 🔴 보안 핫픽스 #267 — AdminUserController GET 4종 권한 우회
|
||||||
- `listUsers`, `userFavorites`, `userReviews`, `userMemos`가 인증만 요구하고 admin 검사를 하지 않아 일반 사용자 토큰으로 전체 사용자 목록 및 타인 활동 조회 가능했음
|
- `listUsers`, `userFavorites`, `userReviews`, `userMemos`가 인증만 요구하고 admin 검사를 하지 않아 일반 사용자 토큰으로 전체 사용자 목록 및 타인 활동 조회 가능했음
|
||||||
- 4개 메서드 첫 줄에 `AuthUtil.requireAdmin()` 추가 → non-admin 호출 시 403
|
- 4개 메서드 첫 줄에 `AuthUtil.requireAdmin()` 추가 → non-admin 호출 시 403
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.tasteby.controller;
|
||||||
|
|
||||||
|
import com.tasteby.security.AuthUtil;
|
||||||
|
import com.tasteby.service.RestaurantService;
|
||||||
|
import com.tasteby.service.RestaurantVerifyService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #322 LLM 검증 어드민 API.
|
||||||
|
* - hidden 토글
|
||||||
|
* - 일괄 백필
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/restaurants")
|
||||||
|
public class AdminRestaurantController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AdminRestaurantController.class);
|
||||||
|
|
||||||
|
private final RestaurantService restaurantService;
|
||||||
|
private final RestaurantVerifyService verifyService;
|
||||||
|
|
||||||
|
public AdminRestaurantController(RestaurantService restaurantService, RestaurantVerifyService verifyService) {
|
||||||
|
this.restaurantService = restaurantService;
|
||||||
|
this.verifyService = verifyService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어드민용 검증 안 된 식당 수 조회.
|
||||||
|
*/
|
||||||
|
@GetMapping("/verify/pending")
|
||||||
|
public Map<String, Object> pendingCount() {
|
||||||
|
var admin = AuthUtil.requireAdmin();
|
||||||
|
int n = restaurantService.countUnverified();
|
||||||
|
log.info("[ADMIN] {} pending verify count: {}", admin.getSubject(), n);
|
||||||
|
return Map.of("pending", n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어드민용 일괄 백필 트리거. 한 번 호출에 모든 미검증 식당을 처리.
|
||||||
|
* 비동기/SSE 없이 동기 응답이라 호출자는 결과까지 기다려야 함(LLM × N).
|
||||||
|
*/
|
||||||
|
@PostMapping("/verify/all")
|
||||||
|
public Map<String, Object> verifyAll(@RequestParam(defaultValue = "10") int batchSize) {
|
||||||
|
var admin = AuthUtil.requireAdmin();
|
||||||
|
log.info("[ADMIN] {} triggered verifyAll(batchSize={})", admin.getSubject(), batchSize);
|
||||||
|
int processed = verifyService.verifyAll(batchSize);
|
||||||
|
return Map.of("processed", processed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어드민용 단건 재검증.
|
||||||
|
*/
|
||||||
|
@PostMapping("/{id}/verify")
|
||||||
|
public Map<String, Object> verifyOne(@PathVariable String id) {
|
||||||
|
var admin = AuthUtil.requireAdmin();
|
||||||
|
log.info("[ADMIN] {} verifyOne({})", admin.getSubject(), id);
|
||||||
|
verifyService.verify(id);
|
||||||
|
return Map.of("success", true, "id", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어드민용 hidden 토글.
|
||||||
|
*/
|
||||||
|
@PatchMapping("/{id}/hidden")
|
||||||
|
public Map<String, Object> setHidden(@PathVariable String id, @RequestBody Map<String, Object> body) {
|
||||||
|
var admin = AuthUtil.requireAdmin();
|
||||||
|
boolean hidden = Boolean.TRUE.equals(body.get("hidden"));
|
||||||
|
String reason = body.get("reason") instanceof String s ? s : "manual";
|
||||||
|
if (hidden) {
|
||||||
|
restaurantService.markHidden(id, reason);
|
||||||
|
} else {
|
||||||
|
restaurantService.clearHidden(id);
|
||||||
|
}
|
||||||
|
log.info("[ADMIN] {} set hidden={} for {}", admin.getSubject(), hidden, id);
|
||||||
|
return Map.of("success", true, "id", id, "hidden", hidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import com.tasteby.security.AuthUtil;
|
|||||||
import com.tasteby.service.CacheService;
|
import com.tasteby.service.CacheService;
|
||||||
import com.tasteby.service.ChannelService;
|
import com.tasteby.service.ChannelService;
|
||||||
import com.tasteby.service.YouTubeService;
|
import com.tasteby.service.YouTubeService;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
@@ -52,16 +53,21 @@ public class ChannelController {
|
|||||||
String channelId = body.get("channel_id");
|
String channelId = body.get("channel_id");
|
||||||
String channelName = body.get("channel_name");
|
String channelName = body.get("channel_name");
|
||||||
String titleFilter = body.get("title_filter");
|
String titleFilter = body.get("title_filter");
|
||||||
|
// #295 — body 필수값 가드 (NOT NULL 컬럼에 빈 값 들어가 500 나는 것 방지)
|
||||||
|
if (channelId == null || channelId.isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "channel_id는 필수입니다");
|
||||||
|
}
|
||||||
|
if (channelName == null || channelName.isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "channel_name은 필수입니다");
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
String id = channelService.create(channelId, channelName, titleFilter);
|
String id = channelService.create(channelId, channelName, titleFilter);
|
||||||
cache.flush();
|
cache.flush();
|
||||||
return Map.of("id", id, "channel_id", channelId);
|
return Map.of("id", id, "channel_id", channelId);
|
||||||
} catch (Exception e) {
|
} catch (DataIntegrityViolationException e) {
|
||||||
if (e.getMessage() != null && e.getMessage().toUpperCase().contains("UQ_CHANNELS_CID")) {
|
// #295 — 유니크 충돌을 메시지 문자열 매칭 대신 typed 예외로 감지 (제약명 변경에도 견고).
|
||||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Channel already exists");
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "Channel already exists");
|
||||||
}
|
}
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{channelId}/scan")
|
@PostMapping("/{channelId}/scan")
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public class DaemonController {
|
|||||||
|
|
||||||
@GetMapping("/config")
|
@GetMapping("/config")
|
||||||
public DaemonConfig getConfig() {
|
public DaemonConfig getConfig() {
|
||||||
|
// #275 — 데몬 운영 설정은 admin 전용 (이전: 공개 노출 — 정보 누출 위험)
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
DaemonConfig config = daemonConfigService.getConfig();
|
DaemonConfig config = daemonConfigService.getConfig();
|
||||||
return config != null ? config : DaemonConfig.builder().build();
|
return config != null ? config : DaemonConfig.builder().build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.tasteby.security.AuthUtil;
|
|||||||
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 jakarta.annotation.PreDestroy;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -47,6 +48,12 @@ public class RestaurantController {
|
|||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #290 — Bean 종료 시 virtual thread executor를 정리하여 리소스 누수 방지.
|
||||||
|
@PreDestroy
|
||||||
|
public void shutdownExecutor() {
|
||||||
|
executor.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<Restaurant> list(
|
public List<Restaurant> list(
|
||||||
@RequestParam(defaultValue = "100") int limit,
|
@RequestParam(defaultValue = "100") int limit,
|
||||||
@@ -61,7 +68,7 @@ public class RestaurantController {
|
|||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
try {
|
try {
|
||||||
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
|
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
|
||||||
} catch (Exception ignored) {}
|
} 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);
|
||||||
cache.set(key, result);
|
cache.set(key, result);
|
||||||
@@ -75,7 +82,7 @@ public class RestaurantController {
|
|||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
try {
|
try {
|
||||||
return objectMapper.readValue(cached, Restaurant.class);
|
return objectMapper.readValue(cached, Restaurant.class);
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
|
||||||
}
|
}
|
||||||
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");
|
||||||
@@ -241,6 +248,10 @@ public class RestaurantController {
|
|||||||
var r = restaurantService.findById(id);
|
var r = restaurantService.findById(id);
|
||||||
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 차단. 빈 문자열은 매핑 해제로 허용.
|
||||||
|
if (url != null && !url.isBlank() && !url.startsWith("https://tabling.co.kr/")) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "테이블링 URL은 https://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();
|
||||||
return Map.of("ok", true);
|
return Map.of("ok", true);
|
||||||
@@ -367,6 +378,12 @@ public class RestaurantController {
|
|||||||
var r = restaurantService.findById(id);
|
var r = restaurantService.findById(id);
|
||||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||||
String url = body.get("catchtable_url");
|
String url = body.get("catchtable_url");
|
||||||
|
// #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
|
||||||
|
if (url != null && !url.isBlank()
|
||||||
|
&& !url.startsWith("https://app.catchtable.co.kr/")
|
||||||
|
&& !url.startsWith("https://www.catchtable.co.kr/")) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "캐치테이블 URL은 https://(app|www).catchtable.co.kr/ 만 허용");
|
||||||
|
}
|
||||||
restaurantService.update(id, Map.of("catchtable_url", url != null ? url : ""));
|
restaurantService.update(id, Map.of("catchtable_url", url != null ? url : ""));
|
||||||
cache.flush();
|
cache.flush();
|
||||||
return Map.of("ok", true);
|
return Map.of("ok", true);
|
||||||
@@ -379,7 +396,7 @@ public class RestaurantController {
|
|||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
try {
|
try {
|
||||||
return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {});
|
return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {});
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
|
||||||
}
|
}
|
||||||
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");
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public class ReviewController {
|
|||||||
@PathVariable String restaurantId,
|
@PathVariable String restaurantId,
|
||||||
@RequestBody Map<String, Object> body) {
|
@RequestBody Map<String, Object> body) {
|
||||||
String userId = AuthUtil.getUserId();
|
String userId = AuthUtil.getUserId();
|
||||||
double rating = ((Number) body.get("rating")).doubleValue();
|
double rating = requireRating(body.get("rating"));
|
||||||
String text = (String) body.get("review_text");
|
String text = (String) body.get("review_text");
|
||||||
LocalDate visitedAt = body.get("visited_at") != null
|
LocalDate visitedAt = body.get("visited_at") != null
|
||||||
? LocalDate.parse((String) body.get("visited_at")) : null;
|
? LocalDate.parse((String) body.get("visited_at")) : null;
|
||||||
@@ -51,8 +51,7 @@ public class ReviewController {
|
|||||||
@PathVariable String reviewId,
|
@PathVariable String reviewId,
|
||||||
@RequestBody Map<String, Object> body) {
|
@RequestBody Map<String, Object> body) {
|
||||||
String userId = AuthUtil.getUserId();
|
String userId = AuthUtil.getUserId();
|
||||||
Double rating = body.get("rating") != null
|
Double rating = body.get("rating") != null ? requireRating(body.get("rating")) : null;
|
||||||
? ((Number) body.get("rating")).doubleValue() : null;
|
|
||||||
String text = (String) body.get("review_text");
|
String text = (String) body.get("review_text");
|
||||||
LocalDate visitedAt = body.get("visited_at") != null
|
LocalDate visitedAt = body.get("visited_at") != null
|
||||||
? LocalDate.parse((String) body.get("visited_at")) : null;
|
? LocalDate.parse((String) body.get("visited_at")) : null;
|
||||||
@@ -94,4 +93,18 @@ public class ReviewController {
|
|||||||
public List<Restaurant> myFavorites() {
|
public List<Restaurant> myFavorites() {
|
||||||
return reviewService.getUserFavorites(AuthUtil.getUserId());
|
return reviewService.getUserFavorites(AuthUtil.getUserId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #294 — rating 검증: null/비숫자/범위 외 입력은 400.
|
||||||
|
*/
|
||||||
|
private static double requireRating(Object raw) {
|
||||||
|
if (!(raw instanceof Number n)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "rating은 숫자여야 합니다");
|
||||||
|
}
|
||||||
|
double v = n.doubleValue();
|
||||||
|
if (v < 0.0 || v > 5.0 || Double.isNaN(v)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "rating은 0.0 ~ 5.0 범위여야 합니다");
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package com.tasteby.controller;
|
|||||||
|
|
||||||
import com.tasteby.domain.Restaurant;
|
import com.tasteby.domain.Restaurant;
|
||||||
import com.tasteby.service.SearchService;
|
import com.tasteby.service.SearchService;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -21,7 +23,12 @@ public class SearchController {
|
|||||||
@RequestParam String q,
|
@RequestParam String q,
|
||||||
@RequestParam(defaultValue = "keyword") String mode,
|
@RequestParam(defaultValue = "keyword") String mode,
|
||||||
@RequestParam(defaultValue = "20") int limit) {
|
@RequestParam(defaultValue = "20") int limit) {
|
||||||
|
// #293 — q 빈값 가드: '%%' LIKE로 응답 폭발 차단
|
||||||
|
if (q == null || q.isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "검색어가 필요합니다");
|
||||||
|
}
|
||||||
if (limit > 100) limit = 100;
|
if (limit > 100) limit = 100;
|
||||||
return searchService.search(q, mode, limit);
|
if (limit < 1) limit = 1;
|
||||||
|
return searchService.search(q.trim(), mode, limit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ public class Restaurant {
|
|||||||
private Integer ratingCount;
|
private Integer ratingCount;
|
||||||
private Date updatedAt;
|
private Date updatedAt;
|
||||||
|
|
||||||
|
// #322 LLM 검증
|
||||||
|
private Boolean hidden;
|
||||||
|
private String hiddenReason;
|
||||||
|
private Date verifiedAt;
|
||||||
|
|
||||||
// Transient enrichment fields
|
// Transient enrichment fields
|
||||||
private List<String> channels;
|
private List<String> channels;
|
||||||
private List<String> foodsMentioned;
|
private List<String> foodsMentioned;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import lombok.NoArgsConstructor;
|
|||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class SiteVisitStats {
|
public class SiteVisitStats {
|
||||||
private int today;
|
// #274 — long으로 변경 (21억 이상 누적 시 int 오버플로 방지)
|
||||||
private int total;
|
private long today;
|
||||||
|
private long total;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,19 @@ public interface RestaurantMapper {
|
|||||||
@Param("offset") int offset,
|
@Param("offset") int offset,
|
||||||
@Param("cuisine") String cuisine,
|
@Param("cuisine") String cuisine,
|
||||||
@Param("region") String region,
|
@Param("region") String region,
|
||||||
@Param("channel") String channel);
|
@Param("channel") String channel,
|
||||||
|
@Param("includeHidden") boolean includeHidden);
|
||||||
|
|
||||||
|
// #322 LLM 검증: hidden 표시 갱신
|
||||||
|
void updateVerification(@Param("id") String id,
|
||||||
|
@Param("hidden") int hidden,
|
||||||
|
@Param("hiddenReason") String hiddenReason);
|
||||||
|
|
||||||
|
void clearHidden(@Param("id") String id);
|
||||||
|
|
||||||
|
List<Restaurant> findUnverified(@Param("limit") int limit);
|
||||||
|
|
||||||
|
int countUnverified();
|
||||||
|
|
||||||
Restaurant findById(@Param("id") String id);
|
Restaurant findById(@Param("id") String id);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ public interface StatsMapper {
|
|||||||
|
|
||||||
void recordVisit();
|
void recordVisit();
|
||||||
|
|
||||||
int getTodayVisits();
|
long getTodayVisits();
|
||||||
|
|
||||||
int getTotalVisits();
|
long getTotalVisits();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import com.google.api.client.http.javanet.NetHttpTransport;
|
|||||||
import com.google.api.client.json.gson.GsonFactory;
|
import com.google.api.client.json.gson.GsonFactory;
|
||||||
import com.tasteby.domain.UserInfo;
|
import com.tasteby.domain.UserInfo;
|
||||||
import com.tasteby.security.JwtTokenProvider;
|
import com.tasteby.security.JwtTokenProvider;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -17,6 +19,8 @@ import java.util.Map;
|
|||||||
@Service
|
@Service
|
||||||
public class AuthService {
|
public class AuthService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
|
||||||
|
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final JwtTokenProvider jwtProvider;
|
private final JwtTokenProvider jwtProvider;
|
||||||
private final GoogleIdTokenVerifier verifier;
|
private final GoogleIdTokenVerifier verifier;
|
||||||
@@ -58,7 +62,10 @@ public class AuthService {
|
|||||||
} catch (ResponseStatusException e) {
|
} catch (ResponseStatusException e) {
|
||||||
throw e;
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid Google token: " + e.getMessage());
|
// #266 — 외부에는 고정 메시지만, 상세는 로그로 (Google verifier 내부 네트워크/공개키
|
||||||
|
// 조회 실패 메시지가 클라이언트에 노출되지 않도록)
|
||||||
|
log.warn("Google token verification failed: {}", e.getMessage());
|
||||||
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid Google token");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,15 @@ public class CacheService {
|
|||||||
this.redis = redis;
|
this.redis = redis;
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
this.ttl = Duration.ofSeconds(ttlSeconds);
|
this.ttl = Duration.ofSeconds(ttlSeconds);
|
||||||
try {
|
// #276 — ping 연결 자원 누수 방지: try-with-resources
|
||||||
redis.getConnectionFactory().getConnection().ping();
|
var factory = redis.getConnectionFactory();
|
||||||
|
if (factory == null) {
|
||||||
|
log.warn("Redis ConnectionFactory is null, caching disabled");
|
||||||
|
disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (var conn = factory.getConnection()) {
|
||||||
|
conn.ping();
|
||||||
log.info("Redis connected");
|
log.info("Redis connected");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Redis unavailable ({}), caching disabled", e.getMessage());
|
log.warn("Redis unavailable ({}), caching disabled", e.getMessage());
|
||||||
@@ -37,6 +44,13 @@ public class CacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String makeKey(String... parts) {
|
public String makeKey(String... parts) {
|
||||||
|
// #276 — null/빈 파트로 "tasteby::" 같은 잘못된 키 생성 방지
|
||||||
|
if (parts == null || parts.length == 0) {
|
||||||
|
throw new IllegalArgumentException("makeKey requires at least one part");
|
||||||
|
}
|
||||||
|
for (String p : parts) {
|
||||||
|
if (p == null) throw new IllegalArgumentException("makeKey parts must not be null");
|
||||||
|
}
|
||||||
return PREFIX + String.join(":", parts);
|
return PREFIX + String.join(":", parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,4 +99,14 @@ public class CacheService {
|
|||||||
log.debug("Cache flush error: {}", e.getMessage());
|
log.debug("Cache flush error: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #290 — 단일 키 삭제 (캐시 역직렬화 실패 시 자동 evict 등에 사용)
|
||||||
|
public void del(String key) {
|
||||||
|
if (disabled) return;
|
||||||
|
try {
|
||||||
|
redis.delete(key);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Cache del error: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,16 @@ public class ChannelService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean deactivate(String channelId) {
|
public boolean deactivate(String channelId) {
|
||||||
// Try deactivate by channel_id first, then by DB id
|
if (channelId == null || channelId.isBlank()) return false;
|
||||||
int rows = mapper.deactivateByChannelId(channelId);
|
// #295 — 입력 형식으로 명시적 분기:
|
||||||
if (rows == 0) {
|
// "UC..."(24 chars) 형식 → YouTube channel_id로 비활성화
|
||||||
rows = mapper.deactivateById(channelId);
|
// 그 외(32-char hex UUID 등) → DB id로 비활성화
|
||||||
}
|
// 이전: channel_id 시도 → 0이면 id 시도. 우연히 UC가 hex와 같을 확률은 0이지만
|
||||||
|
// 가독성/의도 명확성 + 잘못된 폴백 차단을 위해 명시화.
|
||||||
|
boolean looksLikeYouTubeId = channelId.startsWith("UC") && channelId.length() == 24;
|
||||||
|
int rows = looksLikeYouTubeId
|
||||||
|
? mapper.deactivateByChannelId(channelId)
|
||||||
|
: mapper.deactivateById(channelId);
|
||||||
return rows > 0;
|
return rows > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package com.tasteby.service;
|
|||||||
|
|
||||||
import com.tasteby.domain.DaemonConfig;
|
import com.tasteby.domain.DaemonConfig;
|
||||||
import com.tasteby.mapper.DaemonConfigMapper;
|
import com.tasteby.mapper.DaemonConfigMapper;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -27,20 +29,33 @@ public class DaemonConfigService {
|
|||||||
current.setScanEnabled(Boolean.TRUE.equals(body.get("scan_enabled")));
|
current.setScanEnabled(Boolean.TRUE.equals(body.get("scan_enabled")));
|
||||||
}
|
}
|
||||||
if (body.containsKey("scan_interval_min")) {
|
if (body.containsKey("scan_interval_min")) {
|
||||||
current.setScanIntervalMin(((Number) body.get("scan_interval_min")).intValue());
|
// #275 — 0/음수 입력으로 30초 사이클 폭주 방지. ClassCastException 대신 400.
|
||||||
|
current.setScanIntervalMin(requirePositiveInt(body.get("scan_interval_min"), "scan_interval_min"));
|
||||||
}
|
}
|
||||||
if (body.containsKey("process_enabled")) {
|
if (body.containsKey("process_enabled")) {
|
||||||
current.setProcessEnabled(Boolean.TRUE.equals(body.get("process_enabled")));
|
current.setProcessEnabled(Boolean.TRUE.equals(body.get("process_enabled")));
|
||||||
}
|
}
|
||||||
if (body.containsKey("process_interval_min")) {
|
if (body.containsKey("process_interval_min")) {
|
||||||
current.setProcessIntervalMin(((Number) body.get("process_interval_min")).intValue());
|
current.setProcessIntervalMin(requirePositiveInt(body.get("process_interval_min"), "process_interval_min"));
|
||||||
}
|
}
|
||||||
if (body.containsKey("process_limit")) {
|
if (body.containsKey("process_limit")) {
|
||||||
current.setProcessLimit(((Number) body.get("process_limit")).intValue());
|
current.setProcessLimit(requirePositiveInt(body.get("process_limit"), "process_limit"));
|
||||||
}
|
}
|
||||||
mapper.updateConfig(current);
|
mapper.updateConfig(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** #275 — 양의 정수 가드. 비숫자/0/음수는 400. */
|
||||||
|
private static int requirePositiveInt(Object raw, String field) {
|
||||||
|
if (!(raw instanceof Number n)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + "은(는) 정수여야 합니다");
|
||||||
|
}
|
||||||
|
int v = n.intValue();
|
||||||
|
if (v < 1) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + "은(는) 1 이상이어야 합니다 (폭주 방지)");
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
public void updateLastScan() {
|
public void updateLastScan() {
|
||||||
mapper.updateLastScan();
|
mapper.updateLastScan();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.tasteby.service;
|
|||||||
import com.tasteby.domain.DaemonConfig;
|
import com.tasteby.domain.DaemonConfig;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -22,6 +23,9 @@ public class DaemonScheduler {
|
|||||||
private final PipelineService pipelineService;
|
private final PipelineService pipelineService;
|
||||||
private final CacheService cacheService;
|
private final CacheService cacheService;
|
||||||
|
|
||||||
|
@Value("${app.daemon.enabled:true}")
|
||||||
|
private boolean instanceEnabled;
|
||||||
|
|
||||||
public DaemonScheduler(DaemonConfigService daemonConfigService,
|
public DaemonScheduler(DaemonConfigService daemonConfigService,
|
||||||
YouTubeService youTubeService,
|
YouTubeService youTubeService,
|
||||||
PipelineService pipelineService,
|
PipelineService pipelineService,
|
||||||
@@ -34,6 +38,10 @@ public class DaemonScheduler {
|
|||||||
|
|
||||||
@Scheduled(fixedDelay = 30_000) // Check every 30 seconds
|
@Scheduled(fixedDelay = 30_000) // Check every 30 seconds
|
||||||
public void run() {
|
public void run() {
|
||||||
|
// 인스턴스 차원 차단(dev/prod 동일 DB 공유 환경에서 dev 쪽 동시 폴링 방지).
|
||||||
|
// dev .env: DAEMON_ENABLED=false → 이 인스턴스는 스케줄러 동작 안 함.
|
||||||
|
// prod: 미설정 → 기본 true.
|
||||||
|
if (!instanceEnabled) return;
|
||||||
try {
|
try {
|
||||||
var config = getConfig();
|
var config = getConfig();
|
||||||
if (config == null) return;
|
if (config == null) return;
|
||||||
@@ -42,8 +50,13 @@ public class DaemonScheduler {
|
|||||||
Instant lastScan = config.getLastScanAt() != null ? config.getLastScanAt().toInstant() : null;
|
Instant lastScan = config.getLastScanAt() != null ? config.getLastScanAt().toInstant() : null;
|
||||||
if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.getScanIntervalMin(), ChronoUnit.MINUTES))) {
|
if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.getScanIntervalMin(), ChronoUnit.MINUTES))) {
|
||||||
log.info("Running scheduled channel scan...");
|
log.info("Running scheduled channel scan...");
|
||||||
int newVideos = youTubeService.scanAllChannels();
|
int newVideos = 0;
|
||||||
|
try {
|
||||||
|
newVideos = youTubeService.scanAllChannels();
|
||||||
|
} finally {
|
||||||
|
// #275 — 외부 호출 예외 시에도 last_scan_at을 갱신해 다음 cron까지의 backoff를 보장
|
||||||
daemonConfigService.updateLastScan();
|
daemonConfigService.updateLastScan();
|
||||||
|
}
|
||||||
if (newVideos > 0) {
|
if (newVideos > 0) {
|
||||||
cacheService.flush();
|
cacheService.flush();
|
||||||
log.info("Scan completed: {} new videos", newVideos);
|
log.info("Scan completed: {} new videos", newVideos);
|
||||||
@@ -55,8 +68,12 @@ public class DaemonScheduler {
|
|||||||
Instant lastProcess = config.getLastProcessAt() != null ? config.getLastProcessAt().toInstant() : null;
|
Instant lastProcess = config.getLastProcessAt() != null ? config.getLastProcessAt().toInstant() : null;
|
||||||
if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.getProcessIntervalMin(), ChronoUnit.MINUTES))) {
|
if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.getProcessIntervalMin(), ChronoUnit.MINUTES))) {
|
||||||
log.info("Running scheduled video processing (limit={})...", config.getProcessLimit());
|
log.info("Running scheduled video processing (limit={})...", config.getProcessLimit());
|
||||||
int restaurants = pipelineService.processPending(config.getProcessLimit());
|
int restaurants = 0;
|
||||||
|
try {
|
||||||
|
restaurants = pipelineService.processPending(config.getProcessLimit());
|
||||||
|
} finally {
|
||||||
daemonConfigService.updateLastProcess();
|
daemonConfigService.updateLastProcess();
|
||||||
|
}
|
||||||
if (restaurants > 0) {
|
if (restaurants > 0) {
|
||||||
cacheService.flush();
|
cacheService.flush();
|
||||||
log.info("Processing completed: {} restaurants extracted", restaurants);
|
log.info("Processing completed: {} restaurants extracted", restaurants);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ public class ExtractorService {
|
|||||||
%s
|
%s
|
||||||
- price_range: 가격대 (예: 1만원대, 2-3만원) (string | null)
|
- price_range: 가격대 (예: 1만원대, 2-3만원) (string | null)
|
||||||
- foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성)
|
- foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성)
|
||||||
- evaluation: 평가 내용 (string | null)
|
- evaluation: 평가 내용을 100자 이내로 요약 (string | null)
|
||||||
- guests: 함께한 게스트 (string[])
|
- guests: 함께한 게스트 (string[])
|
||||||
|
|
||||||
영상 제목: {title}
|
영상 제목: {title}
|
||||||
@@ -62,6 +62,10 @@ public class ExtractorService {
|
|||||||
*/
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public ExtractionResult extractRestaurants(String title, String transcript, String customPrompt) {
|
public ExtractionResult extractRestaurants(String title, String transcript, String customPrompt) {
|
||||||
|
// #292 — transcript null/blank 가드 (NPE 방지)
|
||||||
|
if (transcript == null || transcript.isBlank()) {
|
||||||
|
return new ExtractionResult(List.of(), "");
|
||||||
|
}
|
||||||
// Truncate very long transcripts
|
// Truncate very long transcripts
|
||||||
if (transcript.length() > 8000) {
|
if (transcript.length() > 8000) {
|
||||||
transcript = transcript.substring(0, 7000) + "\n...(중략)...\n" + transcript.substring(transcript.length() - 1000);
|
transcript = transcript.substring(0, 7000) + "\n...(중략)...\n" + transcript.substring(transcript.length() - 1000);
|
||||||
|
|||||||
@@ -156,7 +156,15 @@ public class GeocodingService {
|
|||||||
|
|
||||||
if (country.isEmpty() && !city.isEmpty()) country = "한국";
|
if (country.isEmpty() && !city.isEmpty()) country = "한국";
|
||||||
if (country.isEmpty()) return null;
|
if (country.isEmpty()) return null;
|
||||||
return country + "|" + city + "|" + district;
|
// #292 — 빈 토큰은 region 문자열에 포함시키지 않는다(`한국||구` 형식 방지).
|
||||||
|
StringBuilder sb = new StringBuilder(country);
|
||||||
|
if (!city.isEmpty()) {
|
||||||
|
sb.append('|').append(city);
|
||||||
|
if (!district.isEmpty()) sb.append('|').append(district);
|
||||||
|
} else if (!district.isEmpty()) {
|
||||||
|
// city 없이 district만 있는 경우는 정확도 낮으므로 무시
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> geocode(String query) {
|
private Map<String, Object> geocode(String query) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.tasteby.service;
|
|||||||
import com.tasteby.domain.Memo;
|
import com.tasteby.domain.Memo;
|
||||||
import com.tasteby.mapper.MemoMapper;
|
import com.tasteby.mapper.MemoMapper;
|
||||||
import com.tasteby.util.IdGenerator;
|
import com.tasteby.util.IdGenerator;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -25,11 +26,18 @@ public class MemoService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public Memo upsert(String userId, String restaurantId, Double rating, String memoText, LocalDate visitedAt) {
|
public Memo upsert(String userId, String restaurantId, Double rating, String memoText, LocalDate visitedAt) {
|
||||||
String visitedStr = visitedAt != null ? visitedAt.toString() : null;
|
String visitedStr = visitedAt != null ? visitedAt.toString() : null;
|
||||||
|
// #294 — 동시성 가드: 사전 SELECT → 분기 INSERT/UPDATE 패턴은 두 트랜잭션이 동시에 미존재
|
||||||
|
// 판정 후 둘 다 INSERT → UNIQUE 충돌(500). INSERT 우선 시도 후 DuplicateKeyException 시 UPDATE.
|
||||||
Memo existing = mapper.findByUserAndRestaurant(userId, restaurantId);
|
Memo existing = mapper.findByUserAndRestaurant(userId, restaurantId);
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
mapper.updateMemo(userId, restaurantId, rating, memoText, visitedStr);
|
mapper.updateMemo(userId, restaurantId, rating, memoText, visitedStr);
|
||||||
} else {
|
} else {
|
||||||
|
try {
|
||||||
mapper.insertMemo(IdGenerator.newId(), userId, restaurantId, rating, memoText, visitedStr);
|
mapper.insertMemo(IdGenerator.newId(), userId, restaurantId, rating, memoText, visitedStr);
|
||||||
|
} catch (DuplicateKeyException e) {
|
||||||
|
// 동시 INSERT 충돌 → UPDATE로 폴백
|
||||||
|
mapper.updateMemo(userId, restaurantId, rating, memoText, visitedStr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return mapper.findByUserAndRestaurant(userId, restaurantId);
|
return mapper.findByUserAndRestaurant(userId, restaurantId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public class PipelineService {
|
|||||||
private final VideoService videoService;
|
private final VideoService videoService;
|
||||||
private final VectorService vectorService;
|
private final VectorService vectorService;
|
||||||
private final CacheService cacheService;
|
private final CacheService cacheService;
|
||||||
|
private final RestaurantVerifyService verifyService;
|
||||||
|
|
||||||
public PipelineService(YouTubeService youTubeService,
|
public PipelineService(YouTubeService youTubeService,
|
||||||
ExtractorService extractorService,
|
ExtractorService extractorService,
|
||||||
@@ -35,7 +36,8 @@ public class PipelineService {
|
|||||||
RestaurantService restaurantService,
|
RestaurantService restaurantService,
|
||||||
VideoService videoService,
|
VideoService videoService,
|
||||||
VectorService vectorService,
|
VectorService vectorService,
|
||||||
CacheService cacheService) {
|
CacheService cacheService,
|
||||||
|
RestaurantVerifyService verifyService) {
|
||||||
this.youTubeService = youTubeService;
|
this.youTubeService = youTubeService;
|
||||||
this.extractorService = extractorService;
|
this.extractorService = extractorService;
|
||||||
this.geocodingService = geocodingService;
|
this.geocodingService = geocodingService;
|
||||||
@@ -43,6 +45,7 @@ public class PipelineService {
|
|||||||
this.videoService = videoService;
|
this.videoService = videoService;
|
||||||
this.vectorService = vectorService;
|
this.vectorService = vectorService;
|
||||||
this.cacheService = cacheService;
|
this.cacheService = cacheService;
|
||||||
|
this.verifyService = verifyService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,6 +87,9 @@ public class PipelineService {
|
|||||||
String videoDbId = (String) video.get("id");
|
String videoDbId = (String) video.get("id");
|
||||||
String title = (String) video.get("title");
|
String title = (String) video.get("title");
|
||||||
|
|
||||||
|
// #292 — 외부 가시성을 위해 진입 시 processing 전이 (이미 processing이면 no-op)
|
||||||
|
updateVideoStatus(videoDbId, "processing", null, null);
|
||||||
|
|
||||||
var result = extractorService.extractRestaurants(title, transcript, customPrompt);
|
var result = extractorService.extractRestaurants(title, transcript, customPrompt);
|
||||||
if (result.restaurants().isEmpty()) {
|
if (result.restaurants().isEmpty()) {
|
||||||
updateVideoStatus(videoDbId, "done", null, result.rawResponse());
|
updateVideoStatus(videoDbId, "done", null, result.rawResponse());
|
||||||
@@ -102,18 +108,26 @@ public class PipelineService {
|
|||||||
// Build upsert data
|
// Build upsert data
|
||||||
var data = new HashMap<String, Object>();
|
var data = new HashMap<String, Object>();
|
||||||
data.put("name", name);
|
data.put("name", name);
|
||||||
data.put("address", geo != null ? geo.get("formatted_address") : restData.get("address"));
|
|
||||||
data.put("region", restData.get("region"));
|
data.put("region", restData.get("region"));
|
||||||
data.put("latitude", geo != null ? geo.get("latitude") : null);
|
|
||||||
data.put("longitude", geo != null ? geo.get("longitude") : null);
|
|
||||||
data.put("cuisine_type", restData.get("cuisine_type"));
|
data.put("cuisine_type", restData.get("cuisine_type"));
|
||||||
data.put("price_range", restData.get("price_range"));
|
data.put("price_range", restData.get("price_range"));
|
||||||
data.put("google_place_id", geo != null ? geo.get("google_place_id") : null);
|
// #292 — geocode 실패(geo==null) 시 좌표/주소/place_id 등 기존 값 보존하기 위해
|
||||||
data.put("phone", geo != null ? geo.get("phone") : null);
|
// null을 명시적으로 put하지 않는다. upsert 측에서 누락 컬럼은 그대로 유지.
|
||||||
data.put("website", geo != null ? geo.get("website") : null);
|
if (geo != null) {
|
||||||
data.put("business_status", geo != null ? geo.get("business_status") : null);
|
data.put("address", geo.get("formatted_address"));
|
||||||
data.put("rating", geo != null ? geo.get("rating") : null);
|
data.put("latitude", geo.get("latitude"));
|
||||||
data.put("rating_count", geo != null ? geo.get("rating_count") : null);
|
data.put("longitude", geo.get("longitude"));
|
||||||
|
data.put("google_place_id", geo.get("google_place_id"));
|
||||||
|
data.put("phone", geo.get("phone"));
|
||||||
|
data.put("website", geo.get("website"));
|
||||||
|
data.put("business_status", geo.get("business_status"));
|
||||||
|
data.put("rating", geo.get("rating"));
|
||||||
|
data.put("rating_count", geo.get("rating_count"));
|
||||||
|
} else {
|
||||||
|
// geocode 실패한 첫 등록 케이스에서 최소한의 주소(LLM이 추출한 원시값)는 저장
|
||||||
|
Object rawAddr = restData.get("address");
|
||||||
|
if (rawAddr != null) data.put("address", rawAddr);
|
||||||
|
}
|
||||||
|
|
||||||
String restId = restaurantService.upsert(data);
|
String restId = restaurantService.upsert(data);
|
||||||
|
|
||||||
@@ -150,6 +164,9 @@ public class PipelineService {
|
|||||||
|
|
||||||
count++;
|
count++;
|
||||||
log.info("Saved restaurant: {} (geocoded={})", name, geo != null);
|
log.info("Saved restaurant: {} (geocoded={})", name, geo != null);
|
||||||
|
|
||||||
|
// #322 — 등록 직후 비동기 LLM 검증
|
||||||
|
verifyService.verifyAsync(restId);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateVideoStatus(videoDbId, "done", null, result.rawResponse());
|
updateVideoStatus(videoDbId, "done", null, result.rawResponse());
|
||||||
|
|||||||
@@ -21,11 +21,36 @@ public class RestaurantService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel) {
|
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel) {
|
||||||
List<Restaurant> restaurants = mapper.findAll(limit, offset, cuisine, region, channel);
|
return findAll(limit, offset, cuisine, region, channel, 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);
|
||||||
enrichRestaurants(restaurants);
|
enrichRestaurants(restaurants);
|
||||||
return restaurants;
|
return restaurants;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #322 — 검증 상태 갱신
|
||||||
|
public void markHidden(String id, String reason) {
|
||||||
|
mapper.updateVerification(id, 1, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markVerifiedClean(String id) {
|
||||||
|
mapper.updateVerification(id, 0, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearHidden(String id) {
|
||||||
|
mapper.clearHidden(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Restaurant> findUnverified(int limit) {
|
||||||
|
return mapper.findUnverified(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int countUnverified() {
|
||||||
|
return mapper.countUnverified();
|
||||||
|
}
|
||||||
|
|
||||||
public List<Restaurant> findWithoutTabling() {
|
public List<Restaurant> findWithoutTabling() {
|
||||||
return mapper.findWithoutTabling();
|
return mapper.findWithoutTabling();
|
||||||
}
|
}
|
||||||
@@ -117,7 +142,8 @@ public class RestaurantService {
|
|||||||
String id = IdGenerator.newId();
|
String id = IdGenerator.newId();
|
||||||
String foodsJson = foods != null ? JsonUtil.toJson(foods) : null;
|
String foodsJson = foods != null ? JsonUtil.toJson(foods) : null;
|
||||||
String guestsJson = guests != null ? JsonUtil.toJson(guests) : null;
|
String guestsJson = guests != null ? JsonUtil.toJson(guests) : null;
|
||||||
mapper.linkVideoRestaurant(id, videoId, restaurantId, foodsJson, evaluation, guestsJson);
|
String evalJson = JsonUtil.normalizeEvaluation(evaluation);
|
||||||
|
mapper.linkVideoRestaurant(id, videoId, restaurantId, foodsJson, evalJson, guestsJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void updateCuisineType(String id, String cuisineType) {
|
public void updateCuisineType(String id, String cuisineType) {
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.tasteby.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.tasteby.domain.Restaurant;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #322 LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김.
|
||||||
|
* 설계서: docs/design/322-restaurant-llm-verify/README.md
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class RestaurantVerifyService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(RestaurantVerifyService.class);
|
||||||
|
|
||||||
|
private final RestaurantService restaurantService;
|
||||||
|
private final OciGenAiService genAi;
|
||||||
|
private final ObjectMapper jsonMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
// 백필 시 LLM rate-limit 보호용 sleep (ms)
|
||||||
|
private static final long BACKFILL_SLEEP_MS = 200;
|
||||||
|
|
||||||
|
public RestaurantVerifyService(RestaurantService restaurantService, OciGenAiService genAi) {
|
||||||
|
this.restaurantService = restaurantService;
|
||||||
|
this.genAi = genAi;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
|
public void verifyAsync(String restaurantId) {
|
||||||
|
try {
|
||||||
|
verify(restaurantId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("verifyAsync failed for {}: {}", restaurantId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void verify(String restaurantId) {
|
||||||
|
Restaurant r = restaurantService.findById(restaurantId);
|
||||||
|
if (r == null) return;
|
||||||
|
VerifyResult result;
|
||||||
|
try {
|
||||||
|
String prompt = buildPrompt(r);
|
||||||
|
String response = genAi.chat(prompt, 120);
|
||||||
|
result = parseVerifyResponse(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 안전한 기본값: LLM 실패 시 공개 유지(=hidden=0). verified_at은 미설정으로 남겨 재시도 가능.
|
||||||
|
log.warn("verify({}) LLM failed: {} — keeping visible", restaurantId, e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyResult(restaurantId, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미검증(verified_at IS NULL) 식당을 배치로 검증. 운영 trigger 용.
|
||||||
|
* 반환: 이번 호출에서 처리한 개수.
|
||||||
|
*/
|
||||||
|
public int verifyAll(int batchSize) {
|
||||||
|
int total = 0;
|
||||||
|
List<Restaurant> batch;
|
||||||
|
while (!(batch = restaurantService.findUnverified(batchSize)).isEmpty()) {
|
||||||
|
for (Restaurant r : batch) {
|
||||||
|
try {
|
||||||
|
verify(r.getId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("verifyAll({}) failed: {}", r.getId(), e.getMessage());
|
||||||
|
}
|
||||||
|
total++;
|
||||||
|
try { Thread.sleep(BACKFILL_SLEEP_MS); } catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (batch.size() < batchSize) break;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- pure helpers (tested separately) ----
|
||||||
|
|
||||||
|
String buildPrompt(Restaurant r) {
|
||||||
|
String foods = r.getFoodsMentioned() == null || r.getFoodsMentioned().isEmpty()
|
||||||
|
? "(없음)" : String.join(", ", r.getFoodsMentioned());
|
||||||
|
return "당신은 식당 데이터 큐레이터다. 다음 식당이 (1) 실제 운영 식당인지, (2) 흔한 프랜차이즈인지 판정하라.\n\n" +
|
||||||
|
"식당명: " + safe(r.getName()) + "\n" +
|
||||||
|
"주소: " + safe(r.getAddress()) + "\n" +
|
||||||
|
"지역: " + safe(r.getRegion()) + "\n" +
|
||||||
|
"음식 분류: " + safe(r.getCuisineType()) + "\n" +
|
||||||
|
"언급된 음식: " + foods + "\n\n" +
|
||||||
|
"응답 형식(JSON만, 다른 텍스트 없이):\n" +
|
||||||
|
"{\"valid\": true|false, \"is_franchise\": true|false, \"reason\": \"20자 이내\"}\n\n" +
|
||||||
|
"가이드:\n" +
|
||||||
|
"- valid=false: 식당 이름이 사람 이름, 영상 제목 일부, 일반 명사(\"점심\", \"맛집\"), " +
|
||||||
|
"영문 prefix(\"name:\", \"title:\") 등 분명히 식당이 아닌 경우.\n" +
|
||||||
|
"- is_franchise=true: 스타벅스, 맥도날드, 버거킹, 김밥천국, 본죽 등 전국 50개 이상 매장의 흔한 체인.\n" +
|
||||||
|
"- 판단이 모호하면 valid=true, is_franchise=false (보수적).";
|
||||||
|
}
|
||||||
|
|
||||||
|
VerifyResult parseVerifyResponse(String raw) {
|
||||||
|
if (raw == null) return VerifyResult.safeDefault();
|
||||||
|
String json = extractJson(raw);
|
||||||
|
if (json == null) return VerifyResult.safeDefault();
|
||||||
|
try {
|
||||||
|
JsonNode node = jsonMapper.readTree(json);
|
||||||
|
boolean valid = node.path("valid").asBoolean(true);
|
||||||
|
boolean isFranchise = node.path("is_franchise").asBoolean(false);
|
||||||
|
String reason = node.path("reason").asText("");
|
||||||
|
if (reason.length() > 100) reason = reason.substring(0, 100);
|
||||||
|
return new VerifyResult(valid, isFranchise, reason);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return VerifyResult.safeDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyResult(String id, VerifyResult r) {
|
||||||
|
if (!r.valid()) {
|
||||||
|
restaurantService.markHidden(id, truncate("not_restaurant: " + r.reason(), 120));
|
||||||
|
} else if (r.isFranchise()) {
|
||||||
|
restaurantService.markHidden(id, truncate("franchise: " + r.reason(), 120));
|
||||||
|
} else {
|
||||||
|
restaurantService.markVerifiedClean(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Pattern JSON_BLOCK = Pattern.compile("\\{[^{}]*\\}", Pattern.DOTALL);
|
||||||
|
|
||||||
|
private static String extractJson(String raw) {
|
||||||
|
// 우선 그대로 시도
|
||||||
|
String trimmed = raw.trim();
|
||||||
|
if (trimmed.startsWith("{") && trimmed.endsWith("}")) return trimmed;
|
||||||
|
// 마크다운 코드블록 또는 다른 텍스트에 감싸진 경우 정규식 추출
|
||||||
|
Matcher m = JSON_BLOCK.matcher(raw);
|
||||||
|
return m.find() ? m.group() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safe(String s) { return s == null ? "(미상)" : s; }
|
||||||
|
private static String truncate(String s, int max) { return s.length() <= max ? s : s.substring(0, max); }
|
||||||
|
|
||||||
|
public record VerifyResult(boolean valid, boolean isFranchise, String reason) {
|
||||||
|
public static VerifyResult safeDefault() { return new VerifyResult(true, false, "parse_failed"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import com.tasteby.domain.Review;
|
|||||||
import com.tasteby.mapper.ReviewMapper;
|
import com.tasteby.mapper.ReviewMapper;
|
||||||
import com.tasteby.util.IdGenerator;
|
import com.tasteby.util.IdGenerator;
|
||||||
import com.tasteby.util.JsonUtil;
|
import com.tasteby.util.JsonUtil;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -60,10 +61,15 @@ public class ReviewService {
|
|||||||
if (existingId != null) {
|
if (existingId != null) {
|
||||||
mapper.deleteFavorite(userId, restaurantId);
|
mapper.deleteFavorite(userId, restaurantId);
|
||||||
return false;
|
return false;
|
||||||
} else {
|
|
||||||
mapper.insertFavorite(IdGenerator.newId(), userId, restaurantId);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
// #294 — 동시성 가드: 동시 INSERT 시 UNIQUE 충돌 → 한 쪽 500.
|
||||||
|
// INSERT 시도 후 DuplicateKeyException은 "이미 추가됨"으로 간주 (토글 의도는 ON).
|
||||||
|
try {
|
||||||
|
mapper.insertFavorite(IdGenerator.newId(), userId, restaurantId);
|
||||||
|
} catch (DuplicateKeyException ignored) {
|
||||||
|
// 다른 트랜잭션이 먼저 INSERT 함 — 결과는 어쨌든 즐겨찾기 ON.
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Restaurant> getUserFavorites(String userId) {
|
public List<Restaurant> getUserFavorites(String userId) {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.tasteby.service;
|
package com.tasteby.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.tasteby.domain.Restaurant;
|
import com.tasteby.domain.Restaurant;
|
||||||
import com.tasteby.mapper.SearchMapper;
|
import com.tasteby.mapper.SearchMapper;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@@ -12,12 +15,17 @@ import java.util.*;
|
|||||||
public class SearchService {
|
public class SearchService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(SearchService.class);
|
private static final Logger log = LoggerFactory.getLogger(SearchService.class);
|
||||||
|
private static final ObjectMapper JSON = new ObjectMapper();
|
||||||
|
private static final TypeReference<List<Restaurant>> LIST_TYPE = new TypeReference<>() {};
|
||||||
|
|
||||||
private final SearchMapper searchMapper;
|
private final SearchMapper searchMapper;
|
||||||
private final RestaurantService restaurantService;
|
private final RestaurantService restaurantService;
|
||||||
private final VectorService vectorService;
|
private final VectorService vectorService;
|
||||||
private final CacheService cache;
|
private final CacheService cache;
|
||||||
|
|
||||||
|
@Value("${app.search.max-distance:0.57}")
|
||||||
|
private double maxDistance;
|
||||||
|
|
||||||
public SearchService(SearchMapper searchMapper,
|
public SearchService(SearchMapper searchMapper,
|
||||||
RestaurantService restaurantService,
|
RestaurantService restaurantService,
|
||||||
VectorService vectorService,
|
VectorService vectorService,
|
||||||
@@ -33,8 +41,8 @@ public class SearchService {
|
|||||||
String cached = cache.getRaw(key);
|
String cached = cache.getRaw(key);
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
try {
|
try {
|
||||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
// #293 — ObjectMapper 재사용 (필드 static)
|
||||||
return mapper.readValue(cached, new com.fasterxml.jackson.core.type.TypeReference<List<Restaurant>>() {});
|
return JSON.readValue(cached, LIST_TYPE);
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,13 +52,20 @@ public class SearchService {
|
|||||||
case "hybrid" -> {
|
case "hybrid" -> {
|
||||||
var kw = keywordSearch(q, limit);
|
var kw = keywordSearch(q, limit);
|
||||||
var sem = semanticSearch(q, limit);
|
var sem = semanticSearch(q, limit);
|
||||||
|
// #293 — semantic 결과에도 channels 부착 (이전: keyword에만 부착되어 hybrid에서 sem 결과는 채널 누락)
|
||||||
|
if (!sem.isEmpty()) attachChannels(sem);
|
||||||
Set<String> seen = new HashSet<>();
|
Set<String> seen = new HashSet<>();
|
||||||
var merged = new ArrayList<Restaurant>();
|
var merged = new ArrayList<Restaurant>();
|
||||||
for (var r : kw) { if (seen.add(r.getId())) merged.add(r); }
|
for (var r : kw) { if (seen.add(r.getId())) merged.add(r); }
|
||||||
for (var r : sem) { if (seen.add(r.getId())) merged.add(r); }
|
for (var r : sem) { if (seen.add(r.getId())) merged.add(r); }
|
||||||
result = merged.size() > limit ? merged.subList(0, limit) : merged;
|
result = merged.size() > limit ? merged.subList(0, limit) : merged;
|
||||||
}
|
}
|
||||||
default -> result = keywordSearch(q, limit);
|
case "keyword" -> result = keywordSearch(q, limit);
|
||||||
|
default -> {
|
||||||
|
// #293 — 알 수 없는 mode는 silent fallback 대신 경고 로그
|
||||||
|
log.warn("Unknown search mode '{}', falling back to keyword", mode);
|
||||||
|
result = keywordSearch(q, limit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cache.set(key, result);
|
cache.set(key, result);
|
||||||
@@ -58,7 +73,10 @@ public class SearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<Restaurant> keywordSearch(String q, int limit) {
|
private List<Restaurant> keywordSearch(String q, int limit) {
|
||||||
String pattern = "%" + q + "%";
|
// #293 — LIKE 와일드카드 escape: 사용자 입력의 %, _, \ 를 리터럴로 처리.
|
||||||
|
// SQL에서는 ESCAPE '\\' 절을 사용 (SearchMapper.xml).
|
||||||
|
String escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
|
||||||
|
String pattern = "%" + escaped + "%";
|
||||||
List<Restaurant> results = searchMapper.keywordSearch(pattern, limit);
|
List<Restaurant> results = searchMapper.keywordSearch(pattern, limit);
|
||||||
if (!results.isEmpty()) {
|
if (!results.isEmpty()) {
|
||||||
attachChannels(results);
|
attachChannels(results);
|
||||||
@@ -68,7 +86,7 @@ public class SearchService {
|
|||||||
|
|
||||||
private List<Restaurant> semanticSearch(String q, int limit) {
|
private List<Restaurant> semanticSearch(String q, int limit) {
|
||||||
try {
|
try {
|
||||||
var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), 0.57);
|
var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), maxDistance);
|
||||||
if (similar.isEmpty()) return List.of();
|
if (similar.isEmpty()) return List.of();
|
||||||
|
|
||||||
Set<String> seen = new LinkedHashSet<>();
|
Set<String> seen = new LinkedHashSet<>();
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ package com.tasteby.service;
|
|||||||
|
|
||||||
import com.tasteby.domain.SiteVisitStats;
|
import com.tasteby.domain.SiteVisitStats;
|
||||||
import com.tasteby.mapper.StatsMapper;
|
import com.tasteby.mapper.StatsMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class StatsService {
|
public class StatsService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(StatsService.class);
|
||||||
|
|
||||||
private final StatsMapper mapper;
|
private final StatsMapper mapper;
|
||||||
|
|
||||||
public StatsService(StatsMapper mapper) {
|
public StatsService(StatsMapper mapper) {
|
||||||
@@ -14,7 +19,19 @@ public class StatsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void recordVisit() {
|
public void recordVisit() {
|
||||||
|
// #274 — 자정 경계 동시성: 두 트랜잭션이 동시에 'NOT MATCHED' 판정 → 둘 다 INSERT
|
||||||
|
// → PK/UNIQUE 충돌 시 한 쪽 500. 1회 재시도(다음엔 MATCHED → UPDATE 분기).
|
||||||
|
try {
|
||||||
mapper.recordVisit();
|
mapper.recordVisit();
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
log.debug("recordVisit conflict (midnight race), retry once: {}", e.getMessage());
|
||||||
|
try {
|
||||||
|
mapper.recordVisit();
|
||||||
|
} catch (DataIntegrityViolationException retryFail) {
|
||||||
|
// 두 번째 시도도 실패: 카운트 1건 손실은 수용 (운영 영향 미미)
|
||||||
|
log.warn("recordVisit double-conflict, dropping one count: {}", retryFail.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public SiteVisitStats getVisits() {
|
public SiteVisitStats getVisits() {
|
||||||
|
|||||||
@@ -27,12 +27,15 @@ public class VectorService {
|
|||||||
*/
|
*/
|
||||||
public List<Map<String, Object>> searchSimilar(String query, int topK, double maxDistance) {
|
public List<Map<String, Object>> searchSimilar(String query, int topK, double maxDistance) {
|
||||||
List<List<Double>> embeddings = genAi.embedTexts(List.of(query));
|
List<List<Double>> embeddings = genAi.embedTexts(List.of(query));
|
||||||
if (embeddings.isEmpty()) return List.of();
|
// #293 — embeddings 빈/null 가드 (NPE/IndexOutOfBoundsException 방지)
|
||||||
|
if (embeddings == null || embeddings.isEmpty()) return List.of();
|
||||||
|
List<Double> first = embeddings.getFirst();
|
||||||
|
if (first == null || first.isEmpty()) return List.of();
|
||||||
|
|
||||||
// Convert to float array for Oracle VECTOR type
|
// Convert to float array for Oracle VECTOR type
|
||||||
float[] queryVec = new float[embeddings.getFirst().size()];
|
float[] queryVec = new float[first.size()];
|
||||||
for (int i = 0; i < queryVec.length; i++) {
|
for (int i = 0; i < queryVec.length; i++) {
|
||||||
queryVec[i] = embeddings.getFirst().get(i).floatValue();
|
queryVec[i] = first.get(i).floatValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
String sql = """
|
String sql = """
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ public class VideoService {
|
|||||||
VideoDetail detail = mapper.findDetail(id);
|
VideoDetail detail = mapper.findDetail(id);
|
||||||
if (detail == null) return null;
|
if (detail == null) return null;
|
||||||
List<VideoRestaurantLink> restaurants = mapper.findVideoRestaurants(id);
|
List<VideoRestaurantLink> restaurants = mapper.findVideoRestaurants(id);
|
||||||
|
if (restaurants != null) {
|
||||||
|
restaurants.forEach(r -> r.setEvaluation(JsonUtil.normalizeEvaluation(r.getEvaluation())));
|
||||||
|
}
|
||||||
detail.setRestaurants(restaurants != null ? restaurants : List.of());
|
detail.setRestaurants(restaurants != null ? restaurants : List.of());
|
||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
@@ -59,6 +62,7 @@ public class VideoService {
|
|||||||
mapper.cleanupOrphanRestaurant(restaurantId);
|
mapper.cleanupOrphanRestaurant(restaurantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
public int saveVideosBatch(String channelId, List<Map<String, Object>> videos) {
|
public int saveVideosBatch(String channelId, List<Map<String, Object>> videos) {
|
||||||
Set<String> existing = new HashSet<>(mapper.getExistingVideoIds(channelId));
|
Set<String> existing = new HashSet<>(mapper.getExistingVideoIds(channelId));
|
||||||
int saved = 0;
|
int saved = 0;
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ public class YouTubeService {
|
|||||||
String uploadsPlaylistId = "UU" + channelId.substring(2);
|
String uploadsPlaylistId = "UU" + channelId.substring(2);
|
||||||
List<Map<String, Object>> allVideos = new ArrayList<>();
|
List<Map<String, Object>> allVideos = new ArrayList<>();
|
||||||
String nextPage = null;
|
String nextPage = null;
|
||||||
|
boolean stopPaging = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
do {
|
do {
|
||||||
@@ -88,7 +89,7 @@ public class YouTubeService {
|
|||||||
// publishedAfter 필터: 이미 스캔한 영상 이후만
|
// publishedAfter 필터: 이미 스캔한 영상 이후만
|
||||||
if (publishedAfter != null && publishedAt.compareTo(publishedAfter) <= 0) {
|
if (publishedAfter != null && publishedAt.compareTo(publishedAfter) <= 0) {
|
||||||
// 업로드 재생목록은 최신순이므로 이전 날짜 만나면 중단
|
// 업로드 재생목록은 최신순이므로 이전 날짜 만나면 중단
|
||||||
nextPage = null;
|
stopPaging = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +106,9 @@ public class YouTubeService {
|
|||||||
}
|
}
|
||||||
allVideos.addAll(pageVideos);
|
allVideos.addAll(pageVideos);
|
||||||
|
|
||||||
if (nextPage != null || data.has("nextPageToken")) {
|
if (stopPaging) {
|
||||||
|
nextPage = null;
|
||||||
|
} else {
|
||||||
nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null;
|
nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null;
|
||||||
}
|
}
|
||||||
} while (nextPage != null);
|
} while (nextPage != null);
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ app:
|
|||||||
cache:
|
cache:
|
||||||
ttl-seconds: 600
|
ttl-seconds: 600
|
||||||
|
|
||||||
|
search:
|
||||||
|
# #293 — 벡터 검색 cosine distance 임계값 (0.0=완전일치, 1.0=직교).
|
||||||
|
# 0.57은 cohere embed-v4 한국어 시맨틱 적합도 기준 경험값.
|
||||||
|
max-distance: ${SEARCH_MAX_DISTANCE:0.57}
|
||||||
|
|
||||||
|
daemon:
|
||||||
|
# 인스턴스 차원 스케줄러 활성화. dev/prod가 같은 DB를 공유하므로
|
||||||
|
# dev .env에 DAEMON_ENABLED=false를 설정해 dev 폴링을 끄고 prod만 동작시킨다.
|
||||||
|
enabled: ${DAEMON_ENABLED:true}
|
||||||
|
|
||||||
mybatis:
|
mybatis:
|
||||||
mapper-locations: classpath:mybatis/mapper/*.xml
|
mapper-locations: classpath:mybatis/mapper/*.xml
|
||||||
config-location: classpath:mybatis/mybatis-config.xml
|
config-location: classpath:mybatis/mybatis-config.xml
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- #322 LLM 검증 — restaurants에 hidden/검증 컬럼 추가
|
||||||
|
ALTER TABLE restaurants ADD (
|
||||||
|
hidden NUMBER(1) DEFAULT 0 NOT NULL,
|
||||||
|
hidden_reason VARCHAR2(120),
|
||||||
|
verified_at TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_restaurants_hidden ON restaurants(hidden);
|
||||||
@@ -44,7 +44,8 @@
|
|||||||
</update>
|
</update>
|
||||||
|
|
||||||
<select id="findByChannelId" resultMap="channelResultMap">
|
<select id="findByChannelId" resultMap="channelResultMap">
|
||||||
SELECT id, channel_id, channel_name, title_filter
|
<!-- #295 — findAllActive와 동일하게 description/tags/sort_order까지 SELECT -->
|
||||||
|
SELECT id, channel_id, channel_name, title_filter, description, tags, sort_order
|
||||||
FROM channels
|
FROM channels
|
||||||
WHERE channel_id = #{channelId} AND is_active = 1
|
WHERE channel_id = #{channelId} AND is_active = 1
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
<result property="rating" column="rating"/>
|
<result property="rating" column="rating"/>
|
||||||
<result property="ratingCount" column="rating_count"/>
|
<result property="ratingCount" column="rating_count"/>
|
||||||
<result property="updatedAt" column="updated_at"/>
|
<result property="updatedAt" column="updated_at"/>
|
||||||
|
<result property="hidden" column="hidden" javaType="java.lang.Boolean"/>
|
||||||
|
<result property="hiddenReason" column="hidden_reason"/>
|
||||||
|
<result property="verifiedAt" column="verified_at"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<!-- ===== Queries ===== -->
|
<!-- ===== Queries ===== -->
|
||||||
@@ -29,7 +32,8 @@
|
|||||||
<select id="findAll" resultMap="restaurantMap">
|
<select id="findAll" resultMap="restaurantMap">
|
||||||
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
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.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.business_status, r.rating, r.rating_count, r.updated_at,
|
||||||
|
r.hidden, r.hidden_reason, r.verified_at
|
||||||
FROM restaurants r
|
FROM restaurants r
|
||||||
<if test="channel != null and channel != ''">
|
<if test="channel != null and channel != ''">
|
||||||
JOIN video_restaurants vr_f ON vr_f.restaurant_id = r.id
|
JOIN video_restaurants vr_f ON vr_f.restaurant_id = r.id
|
||||||
@@ -39,6 +43,9 @@
|
|||||||
<where>
|
<where>
|
||||||
r.latitude IS NOT NULL
|
r.latitude IS NOT NULL
|
||||||
AND EXISTS (SELECT 1 FROM video_restaurants vr0 WHERE vr0.restaurant_id = r.id)
|
AND EXISTS (SELECT 1 FROM video_restaurants vr0 WHERE vr0.restaurant_id = r.id)
|
||||||
|
<if test="includeHidden == null or !includeHidden">
|
||||||
|
AND r.hidden = 0
|
||||||
|
</if>
|
||||||
<if test="cuisine != null and cuisine != ''">
|
<if test="cuisine != null and cuisine != ''">
|
||||||
AND r.cuisine_type = #{cuisine}
|
AND r.cuisine_type = #{cuisine}
|
||||||
</if>
|
</if>
|
||||||
@@ -277,4 +284,35 @@
|
|||||||
ORDER BY r.name
|
ORDER BY r.name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- ===== #322 LLM 검증 ===== -->
|
||||||
|
|
||||||
|
<update id="updateVerification">
|
||||||
|
UPDATE restaurants
|
||||||
|
SET hidden = #{hidden},
|
||||||
|
hidden_reason = #{hiddenReason,jdbcType=VARCHAR},
|
||||||
|
verified_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="clearHidden">
|
||||||
|
UPDATE restaurants
|
||||||
|
SET hidden = 0,
|
||||||
|
hidden_reason = NULL,
|
||||||
|
verified_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="findUnverified" resultMap="restaurantMap">
|
||||||
|
SELECT r.id, r.name, r.address, r.region, r.cuisine_type, r.price_range,
|
||||||
|
r.hidden, r.hidden_reason, r.verified_at
|
||||||
|
FROM restaurants r
|
||||||
|
WHERE r.verified_at IS NULL
|
||||||
|
ORDER BY r.updated_at DESC
|
||||||
|
FETCH FIRST #{limit} ROWS ONLY
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countUnverified" resultType="int">
|
||||||
|
SELECT COUNT(*) FROM restaurants WHERE verified_at IS NULL
|
||||||
|
</select>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -79,7 +79,8 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="getAvgRating" resultType="map">
|
<select id="getAvgRating" resultType="map">
|
||||||
SELECT ROUND(AVG(rating), 1) AS avg_rating, COUNT(*) AS review_count
|
<!-- #294 — review 0건이면 AVG는 NULL → 클라이언트 NaN 처리 부담. NVL로 0.0 보장. -->
|
||||||
|
SELECT NVL(ROUND(AVG(rating), 1), 0) AS avg_rating, COUNT(*) AS review_count
|
||||||
FROM user_reviews
|
FROM user_reviews
|
||||||
WHERE restaurant_id = #{restaurantId}
|
WHERE restaurant_id = #{restaurantId}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -30,12 +30,13 @@
|
|||||||
JOIN video_restaurants vr ON vr.restaurant_id = r.id
|
JOIN video_restaurants vr ON vr.restaurant_id = r.id
|
||||||
JOIN videos v ON v.id = vr.video_id
|
JOIN videos v ON v.id = vr.video_id
|
||||||
WHERE r.latitude IS NOT NULL
|
WHERE r.latitude IS NOT NULL
|
||||||
AND (UPPER(r.name) LIKE UPPER(#{query})
|
<!-- #293 — ESCAPE 절로 사용자 입력의 %, _ 와일드카드 의도 우회 차단 -->
|
||||||
OR UPPER(r.address) LIKE UPPER(#{query})
|
AND (UPPER(r.name) LIKE UPPER(#{query}) ESCAPE '\'
|
||||||
OR UPPER(r.region) LIKE UPPER(#{query})
|
OR UPPER(r.address) LIKE UPPER(#{query}) ESCAPE '\'
|
||||||
OR UPPER(r.cuisine_type) LIKE UPPER(#{query})
|
OR UPPER(r.region) LIKE UPPER(#{query}) ESCAPE '\'
|
||||||
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query})
|
OR UPPER(r.cuisine_type) LIKE UPPER(#{query}) ESCAPE '\'
|
||||||
OR UPPER(v.title) LIKE UPPER(#{query}))
|
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query}) ESCAPE '\'
|
||||||
|
OR UPPER(v.title) LIKE UPPER(#{query}) ESCAPE '\')
|
||||||
FETCH FIRST #{limit} ROWS ONLY
|
FETCH FIRST #{limit} ROWS ONLY
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
|||||||
127
docs/design/316-backend-resource-rightsize/README.md
Normal file
127
docs/design/316-backend-resource-rightsize/README.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# 설계서: backend resource request 재산정 (#316)
|
||||||
|
|
||||||
|
> **상태**: Approved <!-- Draft | Approved | Superseded -->
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #316 · 관련 ADR: 없음 · 부모 이슈: #267 (현행화/배포 컨텍스트)
|
||||||
|
> · 구현 파일: `k8s/backend-deployment.yaml`
|
||||||
|
> · 테스트: kubectl rollout 무중단 (수동 검증) · 자동 테스트 없음
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
|
노드 다운사이징(2 × 2 OCPU/8 GB → 2 × 1 OCPU/6 GB) 이후 `backend` Deployment의 CPU request 500m이 노드 가용 자원의 절반을 차지하여, RollingUpdate 시 신/구 Pod 공존이 불가능. 임시로 `maxSurge=0, maxUnavailable=1` 패치(매 배포 ~30초 다운타임) 상태를 합리화하여 25%/25% 정책으로 복귀하고 무중단 배포를 회복한다.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
|
||||||
|
- **포함**
|
||||||
|
- `k8s/backend-deployment.yaml`의 `resources.requests`/`limits` 재산정.
|
||||||
|
- 같은 파일의 `spec.strategy` 또는 라이브 deploy의 strategy를 25%/25%로 복귀(이미 패치되어 있다면 디폴트로 환원).
|
||||||
|
- **제외 (out of scope)**
|
||||||
|
- frontend/redis 등 다른 Deployment.
|
||||||
|
- JVM heap/-XX 옵션(별도 튜닝 이슈).
|
||||||
|
- HPA, VPA 도입.
|
||||||
|
- 노드 수/형상 추가 변경.
|
||||||
|
|
||||||
|
## 3. 인수조건 (Acceptance Criteria)
|
||||||
|
|
||||||
|
- [ ] backend CPU request ≤ 300m, memory request ≤ 512Mi (실측 ~305 MB 사용 기준 여유 포함).
|
||||||
|
- [ ] limit은 노드 한도(1 OCPU / 6 GB) 안에서 cpu ≤ 800m, mem ≤ 1Gi.
|
||||||
|
- [ ] Deployment strategy: `maxSurge: 25%`, `maxUnavailable: 25%`(또는 default).
|
||||||
|
- [ ] `kubectl apply` 직후 새 Pod이 Pending 없이 Running 진입.
|
||||||
|
- [ ] 적용 동안 `https://www.tasteby.net/api/health` 가 끊김 없이 200 응답.
|
||||||
|
- [ ] 변경 사항을 git 커밋·push.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
- 의존성: OKE 1.34.2, ARM64 노드 1 OCPU/6 GB × 2.
|
||||||
|
- 노드 가용 CPU(allocatable, 시스템 데몬 차감 후): 약 940-960m per node.
|
||||||
|
- 노드 가용 메모리(allocatable): 약 5.0-5.3 GiB per node.
|
||||||
|
- 같은 노드에 frontend(200m/256Mi), kube-system DaemonSet들(약 200m/300Mi 합계), 가끔 redis/cert-manager Pod.
|
||||||
|
- 두 backend Pod이 한 노드에 공존하지 않아도 됨(replicas=1이지만 RollingUpdate 동안 일시적으로 2개).
|
||||||
|
- 제약: 비용 X(코드 변경 없음), 운영 영향(작지만 rollout 한 번 발생).
|
||||||
|
- 가정: 운영 실측 idle CPU 0.7%, peak 추정 30-40% (영상 추출·벡터 검색 시).
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
git: k8s/backend-deployment.yaml
|
||||||
|
│
|
||||||
|
▼ (수정)
|
||||||
|
spec.replicas: 1 (변경 없음)
|
||||||
|
spec.strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxSurge: 25% (=0 → 25%, 임시 패치 복귀)
|
||||||
|
maxUnavailable: 25% (=1 → 25%, 임시 패치 복귀)
|
||||||
|
spec.template.spec.containers[0].resources:
|
||||||
|
requests: { cpu: 300m, memory: 512Mi } (500m/768Mi → 다운)
|
||||||
|
limits: { cpu: 800m, memory: 1024Mi } (1/1536 → 다운)
|
||||||
|
│
|
||||||
|
▼ (kubectl apply)
|
||||||
|
OKE rolling update → 새 Pod 1개 surge → Ready → 구 Pod 종료
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
www.tasteby.net 트래픽 무중단 (Ingress → Service → Pod, 100ms 응답 유지)
|
||||||
|
```
|
||||||
|
|
||||||
|
I/O 경계: 매니페스트(선언)와 클러스터 상태(런타임)는 `kubectl apply`로 단방향 동기화. 검증은 `rollout status` + 외부 curl.
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
|
||||||
|
| 필드 | 변경 전 | 변경 후 | 근거 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| `resources.requests.cpu` | `500m` | `300m` | idle 0.7%, peak 추정 30%, 1 OCPU 노드에서 frontend(200m) + 잔여 시스템(~200m) 후 여유 |
|
||||||
|
| `resources.requests.memory` | `768Mi` | `512Mi` | 실측 ~305 MB, JVM heap·코드캐시·메타스페이스 여유 |
|
||||||
|
| `resources.limits.cpu` | `1` | `800m` | 1 OCPU 노드에서 throttle 방지 + 다른 Pod 여유 |
|
||||||
|
| `resources.limits.memory` | `1536Mi` | `1024Mi` | OOM 위험 줄이고 노드당 5 GiB allocatable에서 안정 |
|
||||||
|
| `strategy.rollingUpdate.maxSurge` | `0` (임시) | `25%` | 무중단 RollingUpdate 복귀 |
|
||||||
|
| `strategy.rollingUpdate.maxUnavailable` | `1` (임시) | `25%` | 동일 |
|
||||||
|
|
||||||
|
## 7. 함수 명세
|
||||||
|
|
||||||
|
매니페스트 변경이라 코드 함수 없음. **변경 단위 표**:
|
||||||
|
|
||||||
|
| 변경 단위 | 위치 | 책임 | 검증 |
|
||||||
|
|-----------|------|------|------|
|
||||||
|
| `requests.cpu` 다운 | `k8s/backend-deployment.yaml:37` | 새 Pod 스케줄링 가능 | `kubectl describe pod` Events에 FailedScheduling 없음 |
|
||||||
|
| `requests.memory` 다운 | `k8s/backend-deployment.yaml:38` | 같음 + OOM 안전 | Pod RSS / requests = 70% 이하 |
|
||||||
|
| `limits.cpu` 다운 | `k8s/backend-deployment.yaml:40` | throttle 제어 | `cpu.stat` throttled_usec 안 늘어남 |
|
||||||
|
| `limits.memory` 다운 | `k8s/backend-deployment.yaml:41` | OOM 보호 | OOMKilled 없음 |
|
||||||
|
| `strategy` 추가 | `k8s/backend-deployment.yaml` spec | 25%/25% 명시 | live patch와 GitOps 일치 |
|
||||||
|
|
||||||
|
> 모두 단순 선언적 변경. 복잡 함수 별도 fn-*.md 불필요.
|
||||||
|
|
||||||
|
## 8. 흐름 / 알고리즘
|
||||||
|
|
||||||
|
1. `k8s/backend-deployment.yaml` 편집(resources + strategy).
|
||||||
|
2. `kubectl apply -f k8s/backend-deployment.yaml`.
|
||||||
|
3. 신 Pod 1개 surge → Ready 대기 (~30-60초, JVM startup).
|
||||||
|
4. Ready 되면 구 Pod 종료(graceful, terminationGracePeriodSeconds 기본 30초).
|
||||||
|
5. `kubectl rollout status deploy/backend -n tasteby` PASS.
|
||||||
|
6. 외부 `curl https://www.tasteby.net/api/health` 연속 200 확인.
|
||||||
|
|
||||||
|
## 9. 엣지케이스 & 에러 처리
|
||||||
|
|
||||||
|
- **JVM startup이 readinessProbe initialDelaySeconds(30s)보다 길면**: 새 Pod이 Ready 못 받음 → 구 Pod 유지 → rollout 진행 안 됨. 현재 backend는 보통 20-25초에 Ready.
|
||||||
|
- **노드 가용 메모리 부족**: 두 backend Pod이 한 노드에 갈 경우 ~1 GiB 차지. frontend + DaemonSet 합치면 압박 가능. scheduler가 적절히 분산 기대 (replicas=1 surge 시).
|
||||||
|
- **OCIR 풀 실패**: imagePullBackOff 시 rollout 중단. 이미지 이미 풀돼 있으므로 영향 적음.
|
||||||
|
- **rollback**: 문제 시 `kubectl rollout undo deploy/backend` 또는 git revert + apply.
|
||||||
|
|
||||||
|
## 10. 테스트 계획
|
||||||
|
|
||||||
|
- 수동: 적용 직후 새 Pod Running 확인 + 외부 health 200 연속(약 2분간 5초 간격 polling).
|
||||||
|
- 부하 측정 후속: 운영 부하 24시간 관찰 → CPU throttle/OOM 없음 확인 (별도 follow-up).
|
||||||
|
- 자동 테스트: 해당 없음 (인프라 매니페스트).
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안 검토
|
||||||
|
|
||||||
|
- **선택**: cpu 300m / mem 512Mi.
|
||||||
|
- **대안 A**: cpu 250m / mem 384Mi — 더 여유롭지만 peak 시 throttle 가능성.
|
||||||
|
- **대안 B**: cpu 400m / mem 640Mi — 안전 마진 크지만 25%/25% 복귀해도 두 Pod 공존 불가 가능(노드 잔여 CPU 부족).
|
||||||
|
- **대안 C**: replicas=2 + topologySpreadConstraints — 가용성↑이지만 비용·리소스↑, 현재 노드 한도에서 부적합.
|
||||||
|
- **트레이드오프**: 선택안은 peak에서 약간 빠듯하나 limits 800m로 burst 허용. 운영 24시간 관찰 후 재조정.
|
||||||
|
|
||||||
|
## 12. 미해결 질문
|
||||||
|
|
||||||
|
- peak 시(영상 추출 동시 다수) CPU 실제 사용량 — 현재 metrics-server 미설치라 정확 측정 불가. 추후 설치 후 재산정.
|
||||||
|
- HPA 도입 여부 — 노드 1 OCPU에선 의미 적음. 노드 추가 후 검토.
|
||||||
|
- replicas=2 가용성 강화 — 새 노드 형상에서 메모리 압박 우려, 별도 결정 필요.
|
||||||
187
docs/design/322-restaurant-llm-verify/README.md
Normal file
187
docs/design/322-restaurant-llm-verify/README.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# 설계서: LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김 (#322)
|
||||||
|
|
||||||
|
> **상태**: Approved <!-- Draft | Approved | Superseded -->
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #322 · 관련 ADR: 없음
|
||||||
|
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/RestaurantVerifyService.java`(신규), `backend-java/src/main/java/com/tasteby/domain/Restaurant.java`(필드 3개 추가), `backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml`(컬럼 매핑), `backend-java/src/main/java/com/tasteby/service/RestaurantService.java`(필터링), DB 마이그레이션 SQL
|
||||||
|
> · 테스트: 단위 테스트 신규 (검증 결과 파싱, hidden 필터링)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
|
LLM 추출 과정에서 (a) 식당이 아닌 비식별자(영상 제목, 사람 이름, 일반 명사 등)가 식당으로 잘못 등록되거나 (b) 흔한 프랜차이즈(스타벅스, 맥도날드 등)가 큐레이션 의도와 무관하게 등록되어 사용자 경험을 저해. LLM 2차 검증으로 자동 숨김 처리하고 어드민에서 수동 복구 가능하게 한다.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
|
||||||
|
- **포함**
|
||||||
|
- `restaurants` 테이블 컬럼 추가: `hidden NUMBER(1) DEFAULT 0`, `hidden_reason VARCHAR2(120)`, `verified_at TIMESTAMP`.
|
||||||
|
- 신규 `RestaurantVerifyService`: 단건 검증 + 배치 백필 검증.
|
||||||
|
- `PipelineService.processExtract` 흐름 끝에 검증 호출(신규 등록 자동 검증).
|
||||||
|
- 어드민 API: 일괄 재검증 트리거 + 개별 hidden 토글.
|
||||||
|
- 프론트: 공개 API 응답에서 hidden=true 제외(어드민 응답에는 포함).
|
||||||
|
- **제외 (out of scope)**
|
||||||
|
- 이미지 인식, 메뉴 검증.
|
||||||
|
- 프랜차이즈 매칭 전용 DB/지식베이스(이번엔 LLM 단발 판정).
|
||||||
|
- 어드민 UI 대량 작업(필요 시 후속 이슈).
|
||||||
|
|
||||||
|
## 3. 인수조건 (Acceptance Criteria)
|
||||||
|
|
||||||
|
- [ ] `restaurants` 테이블에 `hidden`/`hidden_reason`/`verified_at` 3개 컬럼이 존재한다.
|
||||||
|
- [ ] 신규 식당 등록 후 60초 이내 `verified_at`이 설정된다.
|
||||||
|
- [ ] `GET /api/restaurants` 응답에 `hidden=1` 식당은 포함되지 않는다.
|
||||||
|
- [ ] `GET /api/admin/restaurants?include_hidden=true` 는 hidden을 포함하고 `hidden_reason`을 노출한다.
|
||||||
|
- [ ] 어드민 `PATCH /api/admin/restaurants/{id}/hidden {hidden:false}` 토글이 정상 동작한다.
|
||||||
|
- [ ] 어드민 `POST /api/admin/restaurants/verify-all` 호출 시 미검증 식당 전체를 백필(rate-limit 적용).
|
||||||
|
- [ ] LLM 호출 실패 시 식당은 hidden=0(공개) 유지(안전한 기본값) + 로그.
|
||||||
|
- [ ] 단위 테스트로 LLM 응답 파싱 + 필터링 로직 통과.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
- 의존성: 기존 `OciGenAiService.chat(prompt, maxTokens)` 재사용.
|
||||||
|
- DB: Oracle 23ai. DDL은 `ALTER TABLE` 마이그레이션.
|
||||||
|
- LLM 비용: 검증은 한 식당당 1회 단발(짧은 프롬프트). 500개 백필 시 약 500 호출.
|
||||||
|
- 봇/quota 제약 없음(OCI GenAI는 내부 호출).
|
||||||
|
- 기존 데이터: 약 500건 식당 → 백필 1회 필요. 신규 영상 처리 흐름에 자동 통합.
|
||||||
|
- 가정: LLM 판정 정확도 85-95%. 실수 시 어드민에서 수동 복구.
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
PipelineService.processExtract
|
||||||
|
│ (기존 흐름)
|
||||||
|
▼
|
||||||
|
RestaurantService.upsert
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RestaurantVerifyService.verifyAsync(restaurantId)
|
||||||
|
│ (비동기 — 사용자 응답 차단 안 함)
|
||||||
|
▼
|
||||||
|
OciGenAiService.chat(prompt, maxTokens=100)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
parseVerifyResponse → { valid: bool, isFranchise: bool, reason: string }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RestaurantMapper.updateVerification(id, hidden, hiddenReason, verifiedAt)
|
||||||
|
│
|
||||||
|
▼ (공개 조회 시)
|
||||||
|
RestaurantService.list(...) → WHERE hidden = 0
|
||||||
|
RestaurantController.adminList(includeHidden=true) → 전체 + hidden_reason 노출
|
||||||
|
```
|
||||||
|
|
||||||
|
I/O ↔ 순수 로직 경계: `parseVerifyResponse`는 순수 함수(LLM 응답 문자열 → 객체). 외부 I/O(LLM 호출, DB write)는 서비스 메서드.
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
|
||||||
|
### DB 마이그레이션
|
||||||
|
```sql
|
||||||
|
ALTER TABLE restaurants ADD (
|
||||||
|
hidden NUMBER(1) DEFAULT 0 NOT NULL,
|
||||||
|
hidden_reason VARCHAR2(120),
|
||||||
|
verified_at TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_restaurants_hidden ON restaurants(hidden);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restaurant 도메인 추가 필드
|
||||||
|
| 필드 | 타입 | 의미 |
|
||||||
|
|------|------|------|
|
||||||
|
| `hidden` | `Boolean` | true면 공개 조회에서 제외 |
|
||||||
|
| `hiddenReason` | `String` | "not_restaurant" / "franchise" / "manual" / null |
|
||||||
|
| `verifiedAt` | `Instant` | 마지막 검증 시각, null이면 미검증 |
|
||||||
|
|
||||||
|
### LLM 응답 스키마
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"valid": true,
|
||||||
|
"is_franchise": false,
|
||||||
|
"reason": "한식 전문점, 로컬"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 함수 명세
|
||||||
|
|
||||||
|
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|
||||||
|
|------|-----------|----------------|------|------|-----------|-------|
|
||||||
|
| `RestaurantVerifyService.verify(id)` | 단건 검증 + DB 반영 | `void verify(String restaurantId)` | restaurantId | side-effect | LLM/DB 예외 → 로그 후 hidden 유지(공개) | **복잡** |
|
||||||
|
| `RestaurantVerifyService.verifyAsync(id)` | 비동기 트리거 | `void verifyAsync(String restaurantId)` | id | - | thread pool 만원 → 다음 cron 처리 | 단순 |
|
||||||
|
| `RestaurantVerifyService.verifyAll(limit)` | 백필(rate-limit 적용) | `int verifyAll(int batchSize)` | batch | 처리된 개수 | LLM rate limit → sleep | **복잡** |
|
||||||
|
| `RestaurantVerifyService.buildPrompt(r)` | 프롬프트 생성 | `String buildPrompt(Restaurant)` | r | 프롬프트 문자열 | - | 단순 |
|
||||||
|
| `RestaurantVerifyService.parseVerifyResponse(s)` | LLM 응답 → DTO | `VerifyResult parse(String)` | LLM raw | DTO | 파싱 실패 → valid=true 기본값(안전) | **복잡** |
|
||||||
|
| `RestaurantMapper.updateVerification(id, hidden, reason, ts)` | DB 갱신 | `int update(...)` | 4 args | 업데이트 행 수 | DB 예외 | 단순 |
|
||||||
|
| `RestaurantService.list()` (수정) | 공개 조회 hidden=0 필터 | `WHERE hidden = 0` 추가 | - | - | - | 단순 |
|
||||||
|
| `AdminRestaurantController.toggleHidden(id)` (신규) | 어드민 수동 토글 | `PATCH /api/admin/restaurants/{id}/hidden` | id, body | success | requireAdmin | 단순 |
|
||||||
|
| `AdminRestaurantController.verifyAll()` (신규) | 백필 트리거 | `POST /api/admin/restaurants/verify-all` | - | 처리 개수 | requireAdmin | 단순 |
|
||||||
|
|
||||||
|
> 복잡 함수는 각각 `fn-verify.md`, `fn-verify-all.md`, `fn-parse-verify-response.md` 후속 분리 가능(현재 후속 이슈로).
|
||||||
|
|
||||||
|
## 8. 흐름 / 알고리즘
|
||||||
|
|
||||||
|
### 신규 등록 검증
|
||||||
|
1. `PipelineService.processExtract` 완료 시 `restaurantId` 획득.
|
||||||
|
2. `RestaurantVerifyService.verifyAsync(restaurantId)` 호출(@Async).
|
||||||
|
3. 별도 스레드에서 `verify(id)` 실행:
|
||||||
|
- 식당 조회 → `buildPrompt` → `OciGenAiService.chat` → `parseVerifyResponse`
|
||||||
|
- `valid=false` 또는 `is_franchise=true`면 hidden=1, reason 설정
|
||||||
|
- `RestaurantMapper.updateVerification` 호출
|
||||||
|
4. 캐시 무효화는 검증 결과가 hidden=1일 때만(공개 목록 변경).
|
||||||
|
|
||||||
|
### 백필
|
||||||
|
1. 어드민 `POST /api/admin/restaurants/verify-all` 호출.
|
||||||
|
2. `verifyAll(batchSize=10)`:
|
||||||
|
- `WHERE verified_at IS NULL` 인 식당 10개 조회 → 순차 검증
|
||||||
|
- 식당당 200ms sleep(LLM rate limit 보호)
|
||||||
|
- 끝까지 반복(`do { ... } while (count == 10)`)
|
||||||
|
3. 전체 카운트 반환.
|
||||||
|
|
||||||
|
### 프롬프트
|
||||||
|
```
|
||||||
|
당신은 식당 데이터 큐레이터다. 다음 식당이 (1) 실제 운영 식당인지, (2) 흔한 프랜차이즈인지 판정하라.
|
||||||
|
|
||||||
|
식당명: {name}
|
||||||
|
주소: {address}
|
||||||
|
지역: {region}
|
||||||
|
음식 분류: {cuisineType}
|
||||||
|
언급된 음식: {foodsMentioned}
|
||||||
|
|
||||||
|
응답 형식(JSON만, 다른 텍스트 없이):
|
||||||
|
{"valid": true|false, "is_franchise": true|false, "reason": "20자 이내"}
|
||||||
|
|
||||||
|
가이드:
|
||||||
|
- valid=false: 식당 이름이 사람 이름, 영상 제목 일부, 일반 명사("점심", "맛집"), 영문 prefix("name:", "title:") 등 분명히 식당이 아닌 경우.
|
||||||
|
- is_franchise=true: 스타벅스, 맥도날드, 버거킹, 김밥천국, 본죽 등 전국 50개 이상 매장의 흔한 체인.
|
||||||
|
- 판단이 모호하면 valid=true, is_franchise=false (보수적).
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 엣지케이스 & 에러 처리
|
||||||
|
|
||||||
|
- **LLM 응답이 JSON 아님**: `parseVerifyResponse`가 JSON 파싱 실패 → valid=true, is_franchise=false 기본값(안전).
|
||||||
|
- **LLM 호출 실패(timeout/quota)**: 로그 후 verified_at 미설정 → 다음 백필에서 재시도.
|
||||||
|
- **LLM이 false negative(잘못된 식당을 정상이라 판정)**: 어드민 수동 토글로 보완.
|
||||||
|
- **LLM이 false positive(정상 식당을 잘못/프랜차이즈로 판정)**: 어드민 수동 hidden=false 토글.
|
||||||
|
- **동시성**: verifyAsync가 같은 ID 두 번 호출돼도 idempotent(같은 결과로 update).
|
||||||
|
- **레이트 리밋**: 백필에서 식당당 200ms sleep + 단건 검증은 별 신경 안 씀(빈도 낮음).
|
||||||
|
|
||||||
|
## 10. 테스트 계획
|
||||||
|
|
||||||
|
- 단위:
|
||||||
|
- `parseVerifyResponse`: 정상 JSON / 파손 JSON / 빈 문자열 / 마크다운 코드블록 포함 케이스.
|
||||||
|
- `buildPrompt`: 모든 필드 채워진 경우 / 일부 null 케이스.
|
||||||
|
- 통합 (수동 또는 후속):
|
||||||
|
- 프랜차이즈 식당 1건 시드 → verifyAll → hidden=1 확인.
|
||||||
|
- 정상 식당 1건 시드 → verifyAll → hidden=0 확인.
|
||||||
|
- 회귀: 기존 `GET /api/restaurants` 응답 구조 변경 없음(필드만 추가, 옵션).
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안 검토
|
||||||
|
|
||||||
|
- **선택**: 단발 LLM 판정 + 어드민 수동 보완.
|
||||||
|
- **대안 A**: 프랜차이즈 DB 자체 구축(스타벅스/맥도날드 등 화이트리스트 매칭) — 정확도↑이지만 운영 부담↑, 신규 프랜차이즈 누락 위험.
|
||||||
|
- **대안 B**: 추출 단계(OciGenAiService.parseJson)에서 한 번에 판정 — 비용↓이지만 추출 로직 비대해짐.
|
||||||
|
- **대안 C**: 이중 검증(LLM A + LLM B 일치 시만 hidden) — 정확도↑↑이지만 비용 2배.
|
||||||
|
- **트레이드오프**: 단발 판정은 비용·복잡도 낮으나 false positive 가능. 어드민 토글로 보완 가능하므로 수용.
|
||||||
|
|
||||||
|
## 12. 미해결 질문
|
||||||
|
|
||||||
|
- 백필 1회 트리거 후 주기적 재검증 필요한가(예: 폐업 식당 자동 hidden)? — 후속.
|
||||||
|
- LLM 비용 모니터링 — 별도 이슈로 분리 권고.
|
||||||
|
- 프랜차이즈 판정 임계값 — 사용자 의견 수렴 필요. 현재 가이드는 "전국 50개 이상".
|
||||||
|
- 어드민 UI에서 일괄 작업(체크박스 + 일괄 hidden 토글) — 별도 이슈.
|
||||||
@@ -1470,12 +1470,12 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
setEditRest({
|
setEditRest({
|
||||||
name: r.name,
|
name: r.name,
|
||||||
cuisine_type: r.cuisine_type || "",
|
cuisine_type: r.cuisine_type || "",
|
||||||
foods_mentioned: r.foods_mentioned.join(", "),
|
foods_mentioned: (r.foods_mentioned || []).join(", "),
|
||||||
evaluation: evalText,
|
evaluation: evalText,
|
||||||
address: r.address || "",
|
address: r.address || "",
|
||||||
region: r.region || "",
|
region: r.region || "",
|
||||||
price_range: r.price_range || "",
|
price_range: r.price_range || "",
|
||||||
guests: r.guests.join(", "),
|
guests: (r.guests || []).join(", "),
|
||||||
});
|
});
|
||||||
} : undefined}
|
} : undefined}
|
||||||
title={isAdmin ? "클릭하여 수정" : undefined}
|
title={isAdmin ? "클릭하여 수정" : undefined}
|
||||||
@@ -1513,7 +1513,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
{r.cuisine_type && <p>종류: {r.cuisine_type}</p>}
|
{r.cuisine_type && <p>종류: {r.cuisine_type}</p>}
|
||||||
{r.price_range && <p>가격대: {r.price_range}</p>}
|
{r.price_range && <p>가격대: {r.price_range}</p>}
|
||||||
</div>
|
</div>
|
||||||
{r.foods_mentioned.length > 0 && (
|
{r.foods_mentioned?.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
{r.foods_mentioned.map((f, j) => (
|
{r.foods_mentioned.map((f, j) => (
|
||||||
<span key={j} className="px-1.5 py-0.5 bg-brand-50 text-brand-700 rounded text-xs">{f}</span>
|
<span key={j} className="px-1.5 py-0.5 bg-brand-50 text-brand-700 rounded text-xs">{f}</span>
|
||||||
@@ -1523,7 +1523,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
{r.evaluation?.text && (
|
{r.evaluation?.text && (
|
||||||
<p className="mt-1 text-xs text-gray-600">{r.evaluation.text}</p>
|
<p className="mt-1 text-xs text-gray-600">{r.evaluation.text}</p>
|
||||||
)}
|
)}
|
||||||
{r.guests.length > 0 && (
|
{r.guests?.length > 0 && (
|
||||||
<p className="mt-1 text-xs text-gray-500">게스트: {r.guests.join(", ")}</p>
|
<p className="mt-1 text-xs text-gray-500">게스트: {r.guests.join(", ")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1627,6 +1627,45 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
// #322/#323 LLM 검증 UI
|
||||||
|
const [verifyPending, setVerifyPending] = useState<number | null>(null);
|
||||||
|
const [verifying, setVerifying] = useState(false);
|
||||||
|
const [verifyResult, setVerifyResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadVerifyPending = useCallback(() => {
|
||||||
|
api.getVerifyPending().then((r) => setVerifyPending(r.pending)).catch(() => setVerifyPending(null));
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { loadVerifyPending(); }, [loadVerifyPending]);
|
||||||
|
|
||||||
|
const handleVerifyAll = async () => {
|
||||||
|
if (!isAdmin) return;
|
||||||
|
if (!confirm(`미검증 식당 ${verifyPending ?? "?"}건을 LLM으로 일괄 검증합니다.\n잘못 등록된 데이터/프랜차이즈를 자동으로 숨김 처리합니다.\n진행할까요?`)) return;
|
||||||
|
setVerifying(true);
|
||||||
|
setVerifyResult(null);
|
||||||
|
try {
|
||||||
|
const r = await api.verifyAll(10);
|
||||||
|
setVerifyResult(`${r.processed}건 검증 완료`);
|
||||||
|
loadVerifyPending();
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
setVerifyResult(`실패: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
} finally {
|
||||||
|
setVerifying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleHidden = async (r: Restaurant) => {
|
||||||
|
if (!isAdmin) return;
|
||||||
|
const becomingHidden = !r.hidden;
|
||||||
|
const reason = becomingHidden ? (prompt("숨김 사유(선택)", "manual") ?? "manual") : "";
|
||||||
|
try {
|
||||||
|
await api.setRestaurantHidden(r.id, becomingHidden, reason || "manual");
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
alert(`실패: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filtered = restaurants.filter((r) => {
|
const filtered = restaurants.filter((r) => {
|
||||||
if (nameSearch && !r.name.toLowerCase().includes(nameSearch.toLowerCase())) return false;
|
if (nameSearch && !r.name.toLowerCase().includes(nameSearch.toLowerCase())) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -1728,6 +1767,18 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (<>
|
{isAdmin && (<>
|
||||||
|
{/* #322/#323 — LLM 검증 */}
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<span>미검증 {verifyPending ?? "?"}건</span>
|
||||||
|
<button
|
||||||
|
onClick={handleVerifyAll}
|
||||||
|
disabled={verifying || verifyPending === 0}
|
||||||
|
className="px-3 py-1 text-xs rounded bg-amber-500 hover:bg-amber-600 text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{verifying ? "검증 중..." : "LLM 검증"}
|
||||||
|
</button>
|
||||||
|
{verifyResult && <span className="text-amber-600">{verifyResult}</span>}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const pending = await fetch(`/api/restaurants/tabling-pending`, {
|
const pending = await fetch(`/api/restaurants/tabling-pending`, {
|
||||||
@@ -1890,6 +1941,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("price_range")}>가격대{sortIcon("price_range")}</th>
|
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("price_range")}>가격대{sortIcon("price_range")}</th>
|
||||||
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("rating")}>평점{sortIcon("rating")}</th>
|
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("rating")}>평점{sortIcon("rating")}</th>
|
||||||
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("business_status")}>상태{sortIcon("business_status")}</th>
|
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("business_status")}>상태{sortIcon("business_status")}</th>
|
||||||
|
<th className="text-center px-4 py-3">검증</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -1917,11 +1969,34 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<span className="text-xs text-gray-400">-</span>
|
<span className="text-xs text-gray-400">-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{r.hidden ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleHidden(r)}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
title={r.hidden_reason || "manual"}
|
||||||
|
className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold hover:bg-red-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
숨김 {r.hidden_reason ? `(${r.hidden_reason.slice(0, 12)})` : ""}
|
||||||
|
</button>
|
||||||
|
) : r.verified_at ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleHidden(r)}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
title="검증 통과 — 클릭하면 숨김"
|
||||||
|
className="px-1.5 py-0.5 bg-green-100 text-green-700 rounded text-[10px] font-semibold hover:bg-green-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">미검증</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{!loading && filtered.length === 0 && (
|
{!loading && filtered.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||||
식당 데이터가 없습니다
|
식당 데이터가 없습니다
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -2144,6 +2219,7 @@ interface AdminUser {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
nickname: string | null;
|
nickname: string | null;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
|
is_admin: boolean;
|
||||||
provider: string | null;
|
provider: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
favorite_count: number;
|
favorite_count: number;
|
||||||
@@ -2246,6 +2322,7 @@ function UsersPanel() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-4 py-2">사용자</th>
|
<th className="text-left px-4 py-2">사용자</th>
|
||||||
<th className="text-left px-4 py-2">이메일</th>
|
<th className="text-left px-4 py-2">이메일</th>
|
||||||
|
<th className="text-center px-4 py-2">관리자</th>
|
||||||
<th className="text-center px-4 py-2">찜</th>
|
<th className="text-center px-4 py-2">찜</th>
|
||||||
<th className="text-center px-4 py-2">리뷰</th>
|
<th className="text-center px-4 py-2">리뷰</th>
|
||||||
<th className="text-center px-4 py-2">메모</th>
|
<th className="text-center px-4 py-2">메모</th>
|
||||||
@@ -2282,6 +2359,27 @@ function UsersPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-gray-500">{u.email || "-"}</td>
|
<td className="px-4 py-2 text-gray-500">{u.email || "-"}</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
<button
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await api.updateAdminUserAdmin(u.id, !u.is_admin);
|
||||||
|
setUsers((prev) => prev.map((x) => x.id === u.id ? { ...x, is_admin: !u.is_admin } : x));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update admin:", err);
|
||||||
|
alert("관리자 권한 변경에 실패했습니다.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
u.is_admin
|
||||||
|
? "bg-green-100 text-green-700 hover:bg-green-200"
|
||||||
|
: "bg-gray-100 text-gray-400 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u.is_admin ? "ON" : "OFF"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2 text-center">
|
<td className="px-4 py-2 text-center">
|
||||||
{u.favorite_count > 0 ? (
|
{u.favorite_count > 0 ? (
|
||||||
<span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 rounded-full text-xs font-medium">
|
<span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 rounded-full text-xs font-medium">
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ import RestaurantDetail from "@/components/RestaurantDetail";
|
|||||||
import MyReviewsList from "@/components/MyReviewsList";
|
import MyReviewsList from "@/components/MyReviewsList";
|
||||||
import BottomSheet from "@/components/BottomSheet";
|
import BottomSheet from "@/components/BottomSheet";
|
||||||
import FilterSheet, { FilterOption } from "@/components/FilterSheet";
|
import FilterSheet, { FilterOption } from "@/components/FilterSheet";
|
||||||
import { getCuisineIcon, getTablerCuisineIcon } from "@/lib/cuisine-icons";
|
import { getCuisineIcon, getPhosphorCuisineIcon } from "@/lib/cuisine-icons";
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "@/components/Icon";
|
||||||
import * as TablerIcons from "@tabler/icons-react";
|
import FoodIcon from "@/components/FoodIcon";
|
||||||
|
import * as PhosphorIcons from "@phosphor-icons/react";
|
||||||
|
|
||||||
function useDragScroll() {
|
function useDragScroll() {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -73,16 +74,24 @@ function matchCuisineFilter(cuisineType: string | null, filter: string): boolean
|
|||||||
|
|
||||||
const PRICE_GROUPS: { label: string; test: (p: string) => boolean }[] = [
|
const PRICE_GROUPS: { label: string; test: (p: string) => boolean }[] = [
|
||||||
{
|
{
|
||||||
label: "저렴 (~1만원)",
|
label: "저렴 (~5천원)",
|
||||||
test: (p) => /저렴|가성비|착한|만원 이하|[3-9]천원|^\d[,.]?\d*천원/.test(p) || /^[1]만원대$/.test(p) || /^[5-9],?\d{3}원/.test(p),
|
test: (p) => /저렴|착한|[3-5]천원대?$|^\d천원$/.test(p),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "가성비 (5천~1만원)",
|
||||||
|
test: (p) => /가성비|만원 이하|[6-9]천원|^1만원대$|^[5-9],?\d{3}원/.test(p),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "보통 (1~3만원)",
|
label: "보통 (1~3만원)",
|
||||||
test: (p) => /[1-2]만원대|1-[23]만|인당 [12]\d?,?\d*원|1[2-9],?\d{3}원|2[0-9],?\d{3}원/.test(p),
|
test: (p) => /[1-2]만원대|1-[23]만|인당 [12]\d?,?\d*원|1[2-9],?\d{3}원|2[0-9],?\d{3}원/.test(p),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "고가 (3만원~)",
|
label: "프리미엄 (3~5만원)",
|
||||||
test: (p) => /[3-9]만원|고가|높은|묵직|살벌|10만원|5만원|4만원|6만원/.test(p),
|
test: (p) => /[3-4]만원대?|3-[45]만|인당 [34]\d?,?\d*원|3[0-9],?\d{3}원|4[0-9],?\d{3}원/.test(p),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "럭셔리 (5만원~)",
|
||||||
|
test: (p) => /[5-9]만원|고가|10만원|[1-9]\d만원|인당 [5-9]\d?,?\d*원|5[0-9],?\d{3}원|[6-9][0-9],?\d{3}원/.test(p),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -304,16 +313,18 @@ export default function Home() {
|
|||||||
api.recordVisit().then(() => api.getVisits().then(setVisits)).catch(console.error);
|
api.recordVisit().then(() => api.getVisits().then(setVisits)).catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load all restaurants on mount
|
// Load restaurants on mount and when channel filter changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setIsSearchResult(false);
|
setIsSearchResult(false);
|
||||||
|
const params: { limit: number; channel?: string } = { limit: 500 };
|
||||||
|
if (channelFilter) params.channel = channelFilter;
|
||||||
api
|
api
|
||||||
.getRestaurants({ limit: 500 })
|
.getRestaurants(params)
|
||||||
.then(setRestaurants)
|
.then(setRestaurants)
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, [channelFilter]);
|
||||||
|
|
||||||
// Auto-select region from user's geolocation (once)
|
// Auto-select region from user's geolocation (once)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -393,7 +404,16 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 검색결과 모드에서 필터 변경 시 검색 결과가 무시되는 결함(#302) 해결.
|
||||||
|
// 검색 모드 플래그를 풀고 원본 restaurants를 다시 로드한다.
|
||||||
|
const exitSearchMode = useCallback(() => {
|
||||||
|
setIsSearchResult(false);
|
||||||
|
setResetCount((c) => c + 1);
|
||||||
|
api.getRestaurants({ limit: 500 }).then(setRestaurants).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCountryChange = useCallback((country: string) => {
|
const handleCountryChange = useCallback((country: string) => {
|
||||||
|
if (isSearchResult) exitSearchMode();
|
||||||
setCountryFilter(country);
|
setCountryFilter(country);
|
||||||
setCityFilter("");
|
setCityFilter("");
|
||||||
setDistrictFilter("");
|
setDistrictFilter("");
|
||||||
@@ -404,9 +424,10 @@ export default function Home() {
|
|||||||
return p && p.country === country;
|
return p && p.country === country;
|
||||||
});
|
});
|
||||||
setRegionFlyTo(computeFlyTo(matched));
|
setRegionFlyTo(computeFlyTo(matched));
|
||||||
}, [restaurants]);
|
}, [restaurants, isSearchResult, exitSearchMode]);
|
||||||
|
|
||||||
const handleCityChange = useCallback((city: string) => {
|
const handleCityChange = useCallback((city: string) => {
|
||||||
|
if (isSearchResult) exitSearchMode();
|
||||||
setCityFilter(city);
|
setCityFilter(city);
|
||||||
setDistrictFilter("");
|
setDistrictFilter("");
|
||||||
if (!city) {
|
if (!city) {
|
||||||
@@ -423,9 +444,10 @@ export default function Home() {
|
|||||||
return p && p.country === countryFilter && p.city === city;
|
return p && p.country === countryFilter && p.city === city;
|
||||||
});
|
});
|
||||||
setRegionFlyTo(computeFlyTo(matched));
|
setRegionFlyTo(computeFlyTo(matched));
|
||||||
}, [restaurants, countryFilter]);
|
}, [restaurants, countryFilter, isSearchResult, exitSearchMode]);
|
||||||
|
|
||||||
const handleDistrictChange = useCallback((district: string) => {
|
const handleDistrictChange = useCallback((district: string) => {
|
||||||
|
if (isSearchResult) exitSearchMode();
|
||||||
setDistrictFilter(district);
|
setDistrictFilter(district);
|
||||||
if (!district) {
|
if (!district) {
|
||||||
const matched = restaurants.filter((r) => {
|
const matched = restaurants.filter((r) => {
|
||||||
@@ -440,7 +462,7 @@ export default function Home() {
|
|||||||
return p && p.country === countryFilter && p.city === cityFilter && p.district === district;
|
return p && p.country === countryFilter && p.city === cityFilter && p.district === district;
|
||||||
});
|
});
|
||||||
setRegionFlyTo(computeFlyTo(matched));
|
setRegionFlyTo(computeFlyTo(matched));
|
||||||
}, [restaurants, countryFilter, cityFilter]);
|
}, [restaurants, countryFilter, cityFilter, isSearchResult, exitSearchMode]);
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -789,10 +811,10 @@ export default function Home() {
|
|||||||
{(cuisineFilter || priceFilter) && (
|
{(cuisineFilter || priceFilter) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setCuisineFilter(""); setPriceFilter(""); }}
|
onClick={() => { setCuisineFilter(""); setPriceFilter(""); }}
|
||||||
className="text-gray-400 hover:text-brand-500 transition-colors"
|
className="p-1.5 -mr-1 text-gray-400 hover:text-brand-500 transition-colors touch-manipulation"
|
||||||
title="음식 필터 초기화"
|
title="음식 필터 초기화"
|
||||||
>
|
>
|
||||||
<Icon name="close" size={12} />
|
<Icon name="close" size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -848,10 +870,10 @@ export default function Home() {
|
|||||||
{countryFilter && (
|
{countryFilter && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); setRegionFlyTo(null); }}
|
onClick={() => { setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); setRegionFlyTo(null); }}
|
||||||
className="text-gray-400 hover:text-brand-500 transition-colors"
|
className="p-1.5 -mr-1 text-gray-400 hover:text-brand-500 transition-colors touch-manipulation"
|
||||||
title="지역 필터 초기화"
|
title="지역 필터 초기화"
|
||||||
>
|
>
|
||||||
<Icon name="close" size={12} />
|
<Icon name="close" size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -867,9 +889,9 @@ export default function Home() {
|
|||||||
setDistrictFilter("");
|
setDistrictFilter("");
|
||||||
setRegionFlyTo(null);
|
setRegionFlyTo(null);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1 rounded-lg px-2 py-1 bg-gray-50 dark:bg-gray-800/50 text-gray-500 dark:text-gray-400 hover:text-brand-500 transition-colors"
|
className="flex items-center gap-1 rounded-lg px-2.5 py-1.5 bg-gray-50 dark:bg-gray-800/50 text-gray-500 dark:text-gray-400 hover:text-brand-500 transition-colors touch-manipulation"
|
||||||
>
|
>
|
||||||
<Icon name="close" size={12} />
|
<Icon name="close" size={14} />
|
||||||
<span>전체보기</span>
|
<span>전체보기</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -999,10 +1021,10 @@ export default function Home() {
|
|||||||
<div ref={dg.ref} onMouseDown={dg.onMouseDown} onMouseMove={dg.onMouseMove} onMouseUp={dg.onMouseUp} onMouseLeave={dg.onMouseLeave} onClickCapture={dg.onClickCapture} style={dg.style} className="flex gap-2 overflow-x-auto scrollbar-hide -mx-1 px-1 pb-1 select-none">
|
<div ref={dg.ref} onMouseDown={dg.onMouseDown} onMouseMove={dg.onMouseMove} onMouseUp={dg.onMouseUp} onMouseLeave={dg.onMouseLeave} onClickCapture={dg.onClickCapture} style={dg.style} className="flex gap-2 overflow-x-auto scrollbar-hide -mx-1 px-1 pb-1 select-none">
|
||||||
{(() => {
|
{(() => {
|
||||||
const allCards = [
|
const allCards = [
|
||||||
{ label: "전체", value: "", icon: "Bowl" },
|
{ label: "전체", value: "", icon: "ForkKnife" },
|
||||||
...CUISINE_TAXONOMY.flatMap((g) => [
|
...CUISINE_TAXONOMY.flatMap((g) => [
|
||||||
{ label: g.category, value: g.category, icon: getTablerCuisineIcon(g.category) },
|
{ label: g.category, value: g.category, icon: getPhosphorCuisineIcon(g.category) },
|
||||||
...g.items.map((item) => ({ label: item, value: `${g.category}|${item}`, icon: getTablerCuisineIcon(`${g.category}|${item}`) })),
|
...g.items.map((item) => ({ label: item, value: `${g.category}|${item}`, icon: getPhosphorCuisineIcon(`${g.category}|${item}`) })),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
return allCards.map((card) => {
|
return allCards.map((card) => {
|
||||||
@@ -1012,7 +1034,7 @@ export default function Home() {
|
|||||||
: isCategory
|
: isCategory
|
||||||
? cuisineFilter === card.value || cuisineFilter.startsWith(card.value + "|")
|
? cuisineFilter === card.value || cuisineFilter.startsWith(card.value + "|")
|
||||||
: cuisineFilter === card.value;
|
: cuisineFilter === card.value;
|
||||||
const TablerIcon = (TablerIcons as unknown as Record<string, React.ComponentType<{ size?: number; stroke?: number; className?: string }>>)[`Icon${card.icon}`] || TablerIcons.IconBowl;
|
const PhIcon = (PhosphorIcons as unknown as Record<string, React.ComponentType<{ size?: number; weight?: string; className?: string }>>)[card.icon] || PhosphorIcons.ForkKnife;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={card.value || "__all__"}
|
key={card.value || "__all__"}
|
||||||
@@ -1029,7 +1051,11 @@ export default function Home() {
|
|||||||
: "bg-white border border-gray-100 text-gray-500"
|
: "bg-white border border-gray-100 text-gray-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<TablerIcon size={22} stroke={1.5} className={selected ? "text-white" : isCategory ? "text-brand-500" : "text-gray-400"} />
|
{card.icon.startsWith("food:") ? (
|
||||||
|
<FoodIcon name={card.icon.slice(5)} size={22} className={selected ? "text-white" : isCategory ? "text-brand-500" : "text-gray-400"} />
|
||||||
|
) : (
|
||||||
|
<PhIcon size={22} className={selected ? "text-white" : isCategory ? "text-brand-500" : "text-gray-400"} />
|
||||||
|
)}
|
||||||
<span className={`text-[11px] whitespace-nowrap ${isCategory ? "font-semibold" : "font-medium"}`}>{card.label}</span>
|
<span className={`text-[11px] whitespace-nowrap ${isCategory ? "font-semibold" : "font-medium"}`}>{card.label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -1459,7 +1485,7 @@ export default function Home() {
|
|||||||
title="음식 장르"
|
title="음식 장르"
|
||||||
options={cuisineOptions}
|
options={cuisineOptions}
|
||||||
value={cuisineFilter}
|
value={cuisineFilter}
|
||||||
onChange={(v) => { setCuisineFilter(v); if (v) setBoundsFilterOn(false); }}
|
onChange={(v) => { if (isSearchResult) exitSearchMode(); setCuisineFilter(v); if (v) setBoundsFilterOn(false); }}
|
||||||
/>
|
/>
|
||||||
<FilterSheet
|
<FilterSheet
|
||||||
open={openSheet === "price"}
|
open={openSheet === "price"}
|
||||||
@@ -1467,7 +1493,7 @@ export default function Home() {
|
|||||||
title="가격대"
|
title="가격대"
|
||||||
options={priceOptions}
|
options={priceOptions}
|
||||||
value={priceFilter}
|
value={priceFilter}
|
||||||
onChange={(v) => { setPriceFilter(v); if (v) setBoundsFilterOn(false); }}
|
onChange={(v) => { if (isSearchResult) exitSearchMode(); setPriceFilter(v); if (v) setBoundsFilterOn(false); }}
|
||||||
/>
|
/>
|
||||||
<FilterSheet
|
<FilterSheet
|
||||||
open={openSheet === "country"}
|
open={openSheet === "country"}
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useEscapeKey, useFocusTrap } from "@/lib/hooks/useModalA11y";
|
||||||
|
|
||||||
interface BottomSheetProps {
|
interface BottomSheetProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
ariaLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SNAP_POINTS = { PEEK: 0.4, HALF: 0.55, FULL: 0.92 };
|
const SNAP_POINTS = { PEEK: 0.4, HALF: 0.55, FULL: 0.92 };
|
||||||
const VELOCITY_THRESHOLD = 0.5;
|
const VELOCITY_THRESHOLD = 0.5;
|
||||||
|
|
||||||
export default function BottomSheet({ open, onClose, children }: BottomSheetProps) {
|
export default function BottomSheet({ open, onClose, children, ariaLabel = "상세 정보" }: BottomSheetProps) {
|
||||||
const sheetRef = useRef<HTMLDivElement>(null);
|
const sheetRef = useRef<HTMLDivElement>(null);
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const [height, setHeight] = useState(SNAP_POINTS.PEEK);
|
const [height, setHeight] = useState(SNAP_POINTS.PEEK);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const dragState = useRef({ startY: 0, startH: 0, lastY: 0, lastTime: 0 });
|
const dragState = useRef({ startY: 0, startH: 0, lastY: 0, lastTime: 0 });
|
||||||
|
|
||||||
|
useEscapeKey(open, onClose);
|
||||||
|
useFocusTrap(open, sheetRef);
|
||||||
|
|
||||||
// Reset to peek when opened
|
// Reset to peek when opened
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) setHeight(SNAP_POINTS.PEEK);
|
if (open) setHeight(SNAP_POINTS.PEEK);
|
||||||
@@ -89,6 +94,10 @@ export default function BottomSheet({ open, onClose, children }: BottomSheetProp
|
|||||||
{/* Sheet */}
|
{/* Sheet */}
|
||||||
<div
|
<div
|
||||||
ref={sheetRef}
|
ref={sheetRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
tabIndex={-1}
|
||||||
className="fixed bottom-0 left-0 right-0 z-50 md:hidden flex flex-col bg-surface/85 backdrop-blur-xl rounded-t-2xl shadow-2xl"
|
className="fixed bottom-0 left-0 right-0 z-50 md:hidden flex flex-col bg-surface/85 backdrop-blur-xl rounded-t-2xl shadow-2xl"
|
||||||
style={{
|
style={{
|
||||||
height: `${height * 100}vh`,
|
height: `${height * 100}vh`,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useRef } from "react";
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "@/components/Icon";
|
||||||
|
import { useEscapeKey, useFocusTrap, useBodyScrollLock } from "@/lib/hooks/useModalA11y";
|
||||||
|
|
||||||
export interface FilterOption {
|
export interface FilterOption {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -20,12 +21,11 @@ interface FilterSheetProps {
|
|||||||
|
|
||||||
export default function FilterSheet({ open, onClose, title, options, value, onChange }: FilterSheetProps) {
|
export default function FilterSheet({ open, onClose, title, options, value, onChange }: FilterSheetProps) {
|
||||||
const sheetRef = useRef<HTMLDivElement>(null);
|
const sheetRef = useRef<HTMLDivElement>(null);
|
||||||
|
const titleId = "filter-sheet-title";
|
||||||
|
|
||||||
useEffect(() => {
|
useBodyScrollLock(open);
|
||||||
if (!open) return;
|
useEscapeKey(open, onClose);
|
||||||
document.body.style.overflow = "hidden";
|
useFocusTrap(open, sheetRef);
|
||||||
return () => { document.body.style.overflow = ""; };
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
// Group options by group field
|
// Group options by group field
|
||||||
const grouped = options.reduce<Record<string, FilterOption[]>>((acc, opt) => {
|
const grouped = options.reduce<Record<string, FilterOption[]>>((acc, opt) => {
|
||||||
@@ -54,6 +54,10 @@ export default function FilterSheet({ open, onClose, title, options, value, onCh
|
|||||||
{/* Sheet */}
|
{/* Sheet */}
|
||||||
<div
|
<div
|
||||||
ref={sheetRef}
|
ref={sheetRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
tabIndex={-1}
|
||||||
className="fixed bottom-0 left-0 right-0 z-[61] md:hidden bg-surface rounded-t-2xl shadow-2xl max-h-[70vh] flex flex-col animate-slide-up"
|
className="fixed bottom-0 left-0 right-0 z-[61] md:hidden bg-surface rounded-t-2xl shadow-2xl max-h-[70vh] flex flex-col animate-slide-up"
|
||||||
>
|
>
|
||||||
{/* Handle */}
|
{/* Handle */}
|
||||||
@@ -63,8 +67,8 @@ export default function FilterSheet({ open, onClose, title, options, value, onCh
|
|||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-gray-800 shrink-0">
|
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-gray-800 shrink-0">
|
||||||
<h3 className="font-bold text-base text-gray-900 dark:text-gray-100">{title}</h3>
|
<h3 id={titleId} className="font-bold text-base text-gray-900 dark:text-gray-100">{title}</h3>
|
||||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600">
|
<button onClick={onClose} aria-label="필터 닫기" className="p-2 -mr-1 text-gray-400 hover:text-gray-600">
|
||||||
<Icon name="close" size={20} />
|
<Icon name="close" size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { GoogleLogin } from "@react-oauth/google";
|
import { GoogleLogin } from "@react-oauth/google";
|
||||||
|
import { useEscapeKey, useFocusTrap, useBodyScrollLock } from "@/lib/hooks/useModalA11y";
|
||||||
|
|
||||||
interface LoginMenuProps {
|
interface LoginMenuProps {
|
||||||
onGoogleSuccess: (credential: string) => void;
|
onGoogleSuccess: (credential: string) => void;
|
||||||
@@ -10,6 +11,22 @@ interface LoginMenuProps {
|
|||||||
|
|
||||||
export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) {
|
export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
const titleId = "login-dialog-title";
|
||||||
|
|
||||||
|
// #283 — 모달 접근성: ESC / focus trap / body scroll lock
|
||||||
|
useEscapeKey(open, () => setOpen(false));
|
||||||
|
useFocusTrap(open, dialogRef);
|
||||||
|
useBodyScrollLock(open);
|
||||||
|
|
||||||
|
const handleSuccess = (res: { credential?: string }) => {
|
||||||
|
setErrorMsg(null);
|
||||||
|
if (res.credential) {
|
||||||
|
onGoogleSuccess(res.credential);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -26,26 +43,34 @@ export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) {
|
|||||||
style={{ zIndex: 99999 }}
|
style={{ zIndex: 99999 }}
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) setOpen(false); }}
|
onClick={(e) => { if (e.target === e.currentTarget) setOpen(false); }}
|
||||||
>
|
>
|
||||||
<div className="bg-surface rounded-2xl shadow-2xl p-6 mx-4 w-full max-w-xs space-y-4">
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="bg-surface rounded-2xl shadow-2xl p-6 mx-4 w-full max-w-sm space-y-4"
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-base font-semibold dark:text-gray-100">로그인</h3>
|
<h3 id={titleId} className="text-base font-semibold dark:text-gray-100">로그인</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-lg leading-none"
|
aria-label="로그인 창 닫기"
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-lg leading-none p-2 -m-2"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500">소셜 계정으로 간편 로그인</p>
|
<p className="text-xs text-gray-400 dark:text-gray-500">소셜 계정으로 간편 로그인</p>
|
||||||
|
{errorMsg && (
|
||||||
|
<p role="alert" className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/40 rounded p-2">
|
||||||
|
{errorMsg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<GoogleLogin
|
<GoogleLogin
|
||||||
onSuccess={(res) => {
|
onSuccess={handleSuccess}
|
||||||
if (res.credential) {
|
onError={() => setErrorMsg("Google 로그인에 실패했습니다. 팝업 차단 또는 네트워크 상태를 확인해주세요.")}
|
||||||
onGoogleSuccess(res.credential);
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onError={() => console.error("Google login failed")}
|
|
||||||
size="large"
|
size="large"
|
||||||
width="260"
|
width="260"
|
||||||
text="signin_with"
|
text="signin_with"
|
||||||
|
|||||||
@@ -67,8 +67,7 @@ type RestaurantProps = { restaurant: Restaurant };
|
|||||||
type RestaurantFeature = Supercluster.PointFeature<RestaurantProps>;
|
type RestaurantFeature = Supercluster.PointFeature<RestaurantProps>;
|
||||||
|
|
||||||
function useSupercluster(restaurants: Restaurant[]) {
|
function useSupercluster(restaurants: Restaurant[]) {
|
||||||
const indexRef = useRef<Supercluster<{ restaurant: Restaurant }> | null>(null);
|
// #278 — indexRef 제거 (set만 되고 read 없는 dead code)
|
||||||
|
|
||||||
const points: RestaurantFeature[] = useMemo(
|
const points: RestaurantFeature[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
restaurants.map((r) => ({
|
restaurants.map((r) => ({
|
||||||
@@ -86,7 +85,6 @@ function useSupercluster(restaurants: Restaurant[]) {
|
|||||||
minPoints: 2,
|
minPoints: 2,
|
||||||
});
|
});
|
||||||
sc.load(points);
|
sc.load(points);
|
||||||
indexRef.current = sc;
|
|
||||||
return sc;
|
return sc;
|
||||||
}, [points]);
|
}, [points]);
|
||||||
|
|
||||||
@@ -129,12 +127,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
|
|||||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||||
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
|
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
|
||||||
|
|
||||||
// Build a lookup for restaurants by id
|
// #278 — restaurantMap 제거 (빌드만 되고 렌더에서 사용 안 됨, dead code)
|
||||||
const restaurantMap = useMemo(() => {
|
|
||||||
const m: Record<string, Restaurant> = {};
|
|
||||||
restaurants.forEach((r) => { m[r.id] = r; });
|
|
||||||
return m;
|
|
||||||
}, [restaurants]);
|
|
||||||
|
|
||||||
const clusters = useMemo(() => {
|
const clusters = useMemo(() => {
|
||||||
if (!bounds) return [];
|
if (!bounds) return [];
|
||||||
@@ -273,7 +266,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
|
|||||||
textDecoration: isClosed ? "line-through" : "none",
|
textDecoration: isClosed ? "line-through" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-rounded" style={{ fontSize: 14, marginRight: 3, verticalAlign: "middle", color: "#E8720C" }}>{getCuisineIcon(r.cuisine_type)}</span>
|
<span className="material-symbols-rounded" style={{ fontSize: 14, width: 14, height: 14, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center", marginRight: 3, verticalAlign: "middle", color: "#E8720C" }}>{getCuisineIcon(r.cuisine_type)}</span>
|
||||||
{r.name}
|
{r.name}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -298,7 +291,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
|
|||||||
>
|
>
|
||||||
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
|
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-bold text-base" style={{ color: "#171717" }}><span className="material-symbols-rounded" style={{ fontSize: 18, verticalAlign: "middle", color: "#E8720C", marginRight: 4 }}>{getCuisineIcon(infoTarget.cuisine_type)}</span>{infoTarget.name}</h3>
|
<h3 className="font-bold text-base" style={{ color: "#171717" }}><span className="material-symbols-rounded" style={{ fontSize: 18, width: 18, height: 18, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center", verticalAlign: "middle", color: "#E8720C", marginRight: 4 }}>{getCuisineIcon(infoTarget.cuisine_type)}</span>{infoTarget.name}</h3>
|
||||||
{infoTarget.business_status === "CLOSED_PERMANENTLY" && (
|
{infoTarget.business_status === "CLOSED_PERMANENTLY" && (
|
||||||
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold">폐업</span>
|
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold">폐업</span>
|
||||||
)}
|
)}
|
||||||
@@ -357,6 +350,13 @@ export default function MapView({ restaurants, selected, onSelectRestaurant, onB
|
|||||||
}, 150);
|
}, 150);
|
||||||
}, [onBoundsChanged]);
|
}, [onBoundsChanged]);
|
||||||
|
|
||||||
|
// #278 — 언마운트 시 디바운스 타이머 정리 (메모리 누수 + unmounted setState 경고 방지)
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<APIProvider apiKey={API_KEY}>
|
<APIProvider apiKey={API_KEY}>
|
||||||
<Map
|
<Map
|
||||||
@@ -380,10 +380,12 @@ export default function MapView({ restaurants, selected, onSelectRestaurant, onB
|
|||||||
{onMyLocation && (
|
{onMyLocation && (
|
||||||
<button
|
<button
|
||||||
onClick={onMyLocation}
|
onClick={onMyLocation}
|
||||||
className="absolute top-2 right-2 w-9 h-9 bg-surface rounded-lg shadow-md flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-brand-500 dark:hover:text-brand-400 transition-colors z-10"
|
aria-label="내 위치로 이동"
|
||||||
|
// #278 — 44×44px 터치 영역 확보 (이전 36px)
|
||||||
|
className="absolute top-2 right-2 w-11 h-11 bg-surface rounded-lg shadow-md flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-brand-500 dark:hover:text-brand-400 transition-colors z-10 touch-manipulation"
|
||||||
title="내 위치"
|
title="내 위치"
|
||||||
>
|
>
|
||||||
<Icon name="my_location" size={20} />
|
<Icon name="my_location" size={22} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{channelNames.length > 0 && (
|
{channelNames.length > 0 && (
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import { api } from "@/lib/api";
|
|||||||
import type { Memo } from "@/lib/api";
|
import type { Memo } from "@/lib/api";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "@/components/Icon";
|
||||||
|
import Stars from "@/components/Stars";
|
||||||
|
|
||||||
interface MemoSectionProps {
|
interface MemoSectionProps {
|
||||||
restaurantId: string;
|
restaurantId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #281 — ReviewSection의 StarSelector와 동일 UX (0.5 단위 + 44px 터치 + ARIA radiogroup)
|
||||||
function StarSelector({
|
function StarSelector({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -18,38 +20,32 @@ function StarSelector({
|
|||||||
onChange: (v: number) => void;
|
onChange: (v: number) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div role="radiogroup" aria-label="별점 선택" className="flex items-center gap-0.5">
|
||||||
<span className="text-xs text-gray-500 mr-1">별점:</span>
|
<span className="text-xs text-gray-500 mr-1">별점:</span>
|
||||||
{[0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5].map((v) => (
|
{[1, 2, 3, 4, 5].map((v) => {
|
||||||
|
const nextVal = value === v ? v - 0.5 : v;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(v)}
|
role="radio"
|
||||||
className={`w-6 h-6 text-xs rounded border ${
|
aria-checked={value >= v - 0.5 && value <= v}
|
||||||
value === v
|
aria-label={`${nextVal}점`}
|
||||||
? "bg-yellow-500 text-white border-yellow-600"
|
onClick={() => onChange(nextVal)}
|
||||||
: "bg-white text-gray-600 border-gray-300 hover:border-yellow-400"
|
className="min-w-[44px] min-h-[44px] flex items-center justify-center touch-manipulation"
|
||||||
}`}
|
title={`${nextVal}점`}
|
||||||
>
|
>
|
||||||
{v}
|
<span className={`text-xl ${v <= value ? "text-yellow-500" : v - 0.5 === value ? "text-yellow-400" : "text-gray-300"}`}>
|
||||||
|
{v <= value ? "★" : v - 0.5 === value ? "⯨" : "☆"}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
{value > 0 && <span className="text-xs text-yellow-600 font-medium ml-1">{value}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StarDisplay({ rating }: { rating: number }) {
|
|
||||||
const stars = [];
|
|
||||||
for (let i = 1; i <= 5; i++) {
|
|
||||||
stars.push(
|
|
||||||
<span key={i} className={rating >= i - 0.5 ? "text-yellow-500" : "text-gray-300"}>
|
|
||||||
★
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <span className="text-sm">{stars}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MemoSection({ restaurantId }: MemoSectionProps) {
|
export default function MemoSection({ restaurantId }: MemoSectionProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [memo, setMemo] = useState<Memo | null>(null);
|
const [memo, setMemo] = useState<Memo | null>(null);
|
||||||
@@ -104,6 +100,9 @@ export default function MemoSection({ restaurantId }: MemoSectionProps) {
|
|||||||
setMemo(saved);
|
setMemo(saved);
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
|
} catch (err) {
|
||||||
|
// #281 — 사용자 피드백
|
||||||
|
alert(`메모 저장 실패: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -111,8 +110,12 @@ export default function MemoSection({ restaurantId }: MemoSectionProps) {
|
|||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!confirm("메모를 삭제하시겠습니까?")) return;
|
if (!confirm("메모를 삭제하시겠습니까?")) return;
|
||||||
|
try {
|
||||||
await api.deleteMemo(restaurantId);
|
await api.deleteMemo(restaurantId);
|
||||||
setMemo(null);
|
setMemo(null);
|
||||||
|
} catch (err) {
|
||||||
|
alert(`메모 삭제 실패: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -167,7 +170,7 @@ export default function MemoSection({ restaurantId }: MemoSectionProps) {
|
|||||||
) : memo ? (
|
) : memo ? (
|
||||||
<div className="border border-brand-200 rounded-lg p-3 bg-brand-50/30">
|
<div className="border border-brand-200 rounded-lg p-3 bg-brand-50/30">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
{memo.rating && <StarDisplay rating={memo.rating} />}
|
{memo.rating && <Stars rating={memo.rating} />}
|
||||||
{memo.visited_at && (
|
{memo.visited_at && (
|
||||||
<span className="text-xs text-gray-400">방문일: {memo.visited_at}</span>
|
<span className="text-xs text-gray-400">방문일: {memo.visited_at}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { Review, Memo } from "@/lib/api";
|
import type { Review, Memo } from "@/lib/api";
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "@/components/Icon";
|
||||||
|
import Stars from "@/components/Stars";
|
||||||
|
|
||||||
interface MyReview extends Review {
|
interface MyReview extends Review {
|
||||||
restaurant_id: string;
|
restaurant_id: string;
|
||||||
@@ -82,9 +83,9 @@ export default function MyReviewsList({
|
|||||||
<span className="font-semibold text-sm truncate">
|
<span className="font-semibold text-sm truncate">
|
||||||
{r.restaurant_name || "알 수 없는 식당"}
|
{r.restaurant_name || "알 수 없는 식당"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-yellow-500 text-sm shrink-0 ml-2">
|
<span className="text-sm shrink-0 ml-2 flex items-center gap-1">
|
||||||
{"★".repeat(Math.round(r.rating))}
|
<Stars rating={r.rating} />
|
||||||
<span className="text-gray-500 ml-1">{r.rating}</span>
|
<span className="text-gray-500">{r.rating}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{r.review_text && (
|
{r.review_text && (
|
||||||
@@ -118,9 +119,9 @@ export default function MyReviewsList({
|
|||||||
{m.restaurant_name || "알 수 없는 식당"}
|
{m.restaurant_name || "알 수 없는 식당"}
|
||||||
</span>
|
</span>
|
||||||
{m.rating && (
|
{m.rating && (
|
||||||
<span className="text-yellow-500 text-sm shrink-0 ml-2">
|
<span className="text-sm shrink-0 ml-2 flex items-center gap-1">
|
||||||
{"★".repeat(Math.round(m.rating))}
|
<Stars rating={m.rating} />
|
||||||
<span className="text-gray-500 ml-1">{m.rating}</span>
|
<span className="text-gray-500">{m.rating}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,19 +23,20 @@ export default function RestaurantDetail({
|
|||||||
const [favLoading, setFavLoading] = useState(false);
|
const [favLoading, setFavLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api
|
api
|
||||||
.getRestaurantVideos(restaurant.id)
|
.getRestaurantVideos(restaurant.id)
|
||||||
.then(setVideos)
|
.then((v) => { if (!cancelled) setVideos(v); })
|
||||||
.catch(() => setVideos([]))
|
.catch(() => { if (!cancelled) setVideos([]); })
|
||||||
.finally(() => setLoading(false));
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
|
||||||
// Load favorite status if logged in
|
|
||||||
if (getToken()) {
|
if (getToken()) {
|
||||||
api.getFavoriteStatus(restaurant.id)
|
api.getFavoriteStatus(restaurant.id)
|
||||||
.then((r) => setFavorited(r.favorited))
|
.then((r) => { if (!cancelled) setFavorited(r.favorited); })
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
return () => { cancelled = true; };
|
||||||
}, [restaurant.id]);
|
}, [restaurant.id]);
|
||||||
|
|
||||||
const handleToggleFavorite = async () => {
|
const handleToggleFavorite = async () => {
|
||||||
@@ -57,12 +58,12 @@ export default function RestaurantDetail({
|
|||||||
<button
|
<button
|
||||||
onClick={handleToggleFavorite}
|
onClick={handleToggleFavorite}
|
||||||
disabled={favLoading}
|
disabled={favLoading}
|
||||||
className={`text-xl leading-none transition-colors ${
|
className={`p-1.5 -m-1.5 transition-colors touch-manipulation ${
|
||||||
favorited ? "text-rose-500" : "text-gray-300 dark:text-gray-600 hover:text-rose-400"
|
favorited ? "text-rose-500" : "text-gray-300 dark:text-gray-600 hover:text-rose-400"
|
||||||
}`}
|
}`}
|
||||||
title={favorited ? "찜 해제" : "찜하기"}
|
title={favorited ? "찜 해제" : "찜하기"}
|
||||||
>
|
>
|
||||||
<Icon name="favorite" size={20} filled={favorited} />
|
<Icon name="favorite" size={22} filled={favorited} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{restaurant.business_status === "CLOSED_PERMANENTLY" && (
|
{restaurant.business_status === "CLOSED_PERMANENTLY" && (
|
||||||
@@ -218,7 +219,7 @@ export default function RestaurantDetail({
|
|||||||
<Icon name="play_circle" size={16} filled className="flex-shrink-0" />
|
<Icon name="play_circle" size={16} filled className="flex-shrink-0" />
|
||||||
{v.title}
|
{v.title}
|
||||||
</a>
|
</a>
|
||||||
{v.foods_mentioned.length > 0 && (
|
{v.foods_mentioned?.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
{v.foods_mentioned.map((f, i) => (
|
{v.foods_mentioned.map((f, i) => (
|
||||||
<span
|
<span
|
||||||
@@ -235,7 +236,7 @@ export default function RestaurantDetail({
|
|||||||
{v.evaluation.text}
|
{v.evaluation.text}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{v.guests.length > 0 && (
|
{v.guests?.length > 0 && (
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
게스트: {v.guests.join(", ")}
|
게스트: {v.guests.join(", ")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -4,37 +4,12 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { Review } from "@/lib/api";
|
import type { Review } from "@/lib/api";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
import Stars from "@/components/Stars";
|
||||||
|
|
||||||
interface ReviewSectionProps {
|
interface ReviewSectionProps {
|
||||||
restaurantId: string;
|
restaurantId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StarDisplay({ rating }: { rating: number }) {
|
|
||||||
const stars = [];
|
|
||||||
for (let i = 1; i <= 5; i++) {
|
|
||||||
if (rating >= i) {
|
|
||||||
stars.push(
|
|
||||||
<span key={i} className="text-yellow-500">
|
|
||||||
★
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
} else if (rating >= i - 0.5) {
|
|
||||||
stars.push(
|
|
||||||
<span key={i} className="text-yellow-500">
|
|
||||||
★
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
stars.push(
|
|
||||||
<span key={i} className="text-gray-300">
|
|
||||||
★
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return <span className="text-sm">{stars}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StarSelector({
|
function StarSelector({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -43,22 +18,30 @@ function StarSelector({
|
|||||||
onChange: (v: number) => void;
|
onChange: (v: number) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div role="radiogroup" aria-label="별점 선택" className="flex items-center gap-0.5">
|
||||||
<span className="text-xs text-gray-500 mr-1">별점:</span>
|
<span className="text-xs text-gray-500 mr-1">별점:</span>
|
||||||
{[0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5].map((v) => (
|
{[1, 2, 3, 4, 5].map((v) => {
|
||||||
|
const isCurrent = value === v || value === v - 0.5;
|
||||||
|
const nextVal = value === v ? v - 0.5 : v;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(v)}
|
role="radio"
|
||||||
className={`w-6 h-6 text-xs rounded border ${
|
aria-checked={value >= v - 0.5 && value <= v}
|
||||||
value === v
|
aria-label={`${nextVal}점`}
|
||||||
? "bg-yellow-500 text-white border-yellow-600"
|
onClick={() => onChange(nextVal)}
|
||||||
: "bg-white text-gray-600 border-gray-300 hover:border-yellow-400"
|
// #281 — 최소 터치 영역 44×44
|
||||||
}`}
|
className="min-w-[44px] min-h-[44px] flex items-center justify-center touch-manipulation"
|
||||||
|
title={`${nextVal}점`}
|
||||||
>
|
>
|
||||||
{v}
|
<span className={`text-xl ${v <= value ? "text-yellow-500" : v - 0.5 === value ? "text-yellow-400" : "text-gray-300"}`}>
|
||||||
|
{v <= value ? "★" : v - 0.5 === value ? "⯨" : "☆"}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
{value > 0 && <span className="text-xs text-yellow-600 font-medium ml-1">{value}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -170,29 +153,42 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
|
|||||||
? reviews.find((r) => r.user_id === user.id)
|
? reviews.find((r) => r.user_id === user.id)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// #281 — API 실패 시 unhandled rejection 방지 + 사용자 피드백
|
||||||
const handleCreate = async (data: {
|
const handleCreate = async (data: {
|
||||||
rating: number;
|
rating: number;
|
||||||
review_text?: string;
|
review_text?: string;
|
||||||
visited_at?: string;
|
visited_at?: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
try {
|
||||||
await api.createReview(restaurantId, data);
|
await api.createReview(restaurantId, data);
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
loadReviews();
|
loadReviews();
|
||||||
|
} catch (e) {
|
||||||
|
alert(`리뷰 작성 실패: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = async (
|
const handleUpdate = async (
|
||||||
reviewId: string,
|
reviewId: string,
|
||||||
data: { rating: number; review_text?: string; visited_at?: string }
|
data: { rating: number; review_text?: string; visited_at?: string }
|
||||||
) => {
|
) => {
|
||||||
|
try {
|
||||||
await api.updateReview(reviewId, data);
|
await api.updateReview(reviewId, data);
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
loadReviews();
|
loadReviews();
|
||||||
|
} catch (e) {
|
||||||
|
alert(`리뷰 수정 실패: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (reviewId: string) => {
|
const handleDelete = async (reviewId: string) => {
|
||||||
if (!confirm("리뷰를 삭제하시겠습니까?")) return;
|
if (!confirm("리뷰를 삭제하시겠습니까?")) return;
|
||||||
|
try {
|
||||||
await api.deleteReview(reviewId);
|
await api.deleteReview(reviewId);
|
||||||
loadReviews();
|
loadReviews();
|
||||||
|
} catch (e) {
|
||||||
|
alert(`리뷰 삭제 실패: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -216,7 +212,7 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
|
|||||||
<>
|
<>
|
||||||
{reviewCount > 0 && avgRating !== null && (
|
{reviewCount > 0 && avgRating !== null && (
|
||||||
<div className="flex items-center gap-2 mb-3 text-sm">
|
<div className="flex items-center gap-2 mb-3 text-sm">
|
||||||
<StarDisplay rating={Math.round(avgRating * 2) / 2} />
|
<Stars rating={Math.round(avgRating * 2) / 2} />
|
||||||
<span className="font-medium">{avgRating.toFixed(1)}</span>
|
<span className="font-medium">{avgRating.toFixed(1)}</span>
|
||||||
<span className="text-gray-500">({reviewCount}개)</span>
|
<span className="text-gray-500">({reviewCount}개)</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,7 +266,7 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
|
|||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{review.user_nickname || "익명"}
|
{review.user_nickname || "익명"}
|
||||||
</span>
|
</span>
|
||||||
<StarDisplay rating={review.rating} />
|
<Stars rating={review.rating} />
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">
|
||||||
{new Date(review.created_at).toLocaleDateString(
|
{new Date(review.created_at).toLocaleDateString(
|
||||||
"ko-KR"
|
"ko-KR"
|
||||||
|
|||||||
37
frontend/src/components/Stars.tsx
Normal file
37
frontend/src/components/Stars.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// #281 공통 별점 컴포넌트 — ReviewSection/MemoSection/MyReviewsList 재사용.
|
||||||
|
// 0.5 단위 시각 구분: 빈 별 위에 황색 절반 별을 절대배치 + clip으로 표시.
|
||||||
|
|
||||||
|
interface StarsProps {
|
||||||
|
rating: number; // 0~5, 0.5 단위
|
||||||
|
size?: "sm" | "md";
|
||||||
|
showNumber?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Stars({ rating, size = "sm", showNumber = false, className = "" }: StarsProps) {
|
||||||
|
const r = Math.max(0, Math.min(5, rating));
|
||||||
|
const textSize = size === "md" ? "text-base" : "text-sm";
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-0.5 ${textSize} ${className}`} aria-label={`${r}점`}>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => {
|
||||||
|
const full = r >= i;
|
||||||
|
const half = !full && r >= i - 0.5;
|
||||||
|
return (
|
||||||
|
<span key={i} className="relative inline-block leading-none">
|
||||||
|
<span className="text-gray-300">★</span>
|
||||||
|
{(full || half) && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 text-yellow-500 overflow-hidden"
|
||||||
|
style={{ width: full ? "100%" : "50%" }}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{showNumber && r > 0 && <span className="text-xs text-yellow-600 font-medium ml-1">{r}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
frontend/src/lib/admin-utils.ts
Normal file
52
frontend/src/lib/admin-utils.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// #304 어드민 페이지 공통 유틸.
|
||||||
|
// 결함: localStorage 직접 접근 10+곳 / SSE 파싱 코드 6곳 중복.
|
||||||
|
|
||||||
|
const TOKEN_KEY = "tasteby_token";
|
||||||
|
|
||||||
|
export function getAdminToken(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authHeaders(): Record<string, string> {
|
||||||
|
const token = getAdminToken();
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSE(Server-Sent Events) 스트림을 라인 단위로 파싱하여 onEvent 콜백을 호출.
|
||||||
|
* 호환 패턴: `data: { ...json... }` 한 줄 = 한 이벤트.
|
||||||
|
* 비어있는 줄은 무시. JSON 파싱 실패 시 콜백 skip.
|
||||||
|
*/
|
||||||
|
export async function consumeSseStream(
|
||||||
|
response: Response,
|
||||||
|
onEvent: (event: unknown) => void,
|
||||||
|
onError?: (err: unknown) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) return;
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith("data:")) continue;
|
||||||
|
const payload = trimmed.slice(5).trim();
|
||||||
|
if (!payload) continue;
|
||||||
|
try {
|
||||||
|
onEvent(JSON.parse(payload));
|
||||||
|
} catch {
|
||||||
|
// 무시: 일부 SSE 줄이 JSON이 아닐 수도 있음
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
onError?.(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,10 @@ export interface Restaurant {
|
|||||||
website: string | null;
|
website: string | null;
|
||||||
channels?: string[];
|
channels?: string[];
|
||||||
foods_mentioned?: string[];
|
foods_mentioned?: string[];
|
||||||
|
// #322 LLM 검증
|
||||||
|
hidden?: boolean;
|
||||||
|
hidden_reason?: string | null;
|
||||||
|
verified_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoLink {
|
export interface VideoLink {
|
||||||
@@ -310,6 +314,7 @@ export const api = {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
nickname: string | null;
|
nickname: string | null;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
|
is_admin: boolean;
|
||||||
provider: string | null;
|
provider: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
favorite_count: number;
|
favorite_count: number;
|
||||||
@@ -320,6 +325,14 @@ export const api = {
|
|||||||
}>(`/api/admin/users${qs ? `?${qs}` : ""}`);
|
}>(`/api/admin/users${qs ? `?${qs}` : ""}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateAdminUserAdmin(userId: string, admin: boolean) {
|
||||||
|
return fetchApi<{ success: boolean }>(`/api/admin/users/${userId}/admin`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ admin }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
getAdminUserFavorites(userId: string) {
|
getAdminUserFavorites(userId: string) {
|
||||||
return fetchApi<
|
return fetchApi<
|
||||||
{
|
{
|
||||||
@@ -567,4 +580,30 @@ export const api = {
|
|||||||
{ method: "POST" }
|
{ method: "POST" }
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// #322 — LLM 검증 어드민 API
|
||||||
|
getVerifyPending() {
|
||||||
|
return fetchApi<{ pending: number }>("/api/admin/restaurants/verify/pending");
|
||||||
|
},
|
||||||
|
verifyAll(batchSize: number = 10) {
|
||||||
|
return fetchApi<{ processed: number }>(
|
||||||
|
`/api/admin/restaurants/verify/all?batchSize=${batchSize}`,
|
||||||
|
{ method: "POST" }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
verifyOne(id: string) {
|
||||||
|
return fetchApi<{ success: boolean; id: string }>(
|
||||||
|
`/api/admin/restaurants/${id}/verify`,
|
||||||
|
{ method: "POST" }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
setRestaurantHidden(id: string, hidden: boolean, reason: string = "manual") {
|
||||||
|
return fetchApi<{ success: boolean; id: string; hidden: boolean }>(
|
||||||
|
`/api/admin/restaurants/${id}/hidden`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ hidden, reason }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
74
frontend/src/lib/hooks/useModalA11y.ts
Normal file
74
frontend/src/lib/hooks/useModalA11y.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function useEscapeKey(active: boolean, onEscape: () => void) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEscape();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handler);
|
||||||
|
return () => document.removeEventListener("keydown", handler);
|
||||||
|
}, [active, onEscape]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FOCUSABLE = [
|
||||||
|
"a[href]",
|
||||||
|
"button:not([disabled])",
|
||||||
|
"input:not([disabled])",
|
||||||
|
"select:not([disabled])",
|
||||||
|
"textarea:not([disabled])",
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
].join(",");
|
||||||
|
|
||||||
|
export function useFocusTrap<T extends HTMLElement>(active: boolean, containerRef: React.RefObject<T | null>) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const node = containerRef.current;
|
||||||
|
if (!node) return;
|
||||||
|
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||||
|
const focusables = () => Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE));
|
||||||
|
const first = focusables()[0];
|
||||||
|
first?.focus();
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
const list = focusables();
|
||||||
|
if (list.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const head = list[0];
|
||||||
|
const tail = list[list.length - 1];
|
||||||
|
const current = document.activeElement as HTMLElement | null;
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (current === head || !node.contains(current)) {
|
||||||
|
e.preventDefault();
|
||||||
|
tail.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (current === tail) {
|
||||||
|
e.preventDefault();
|
||||||
|
head.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handler);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handler);
|
||||||
|
previouslyFocused?.focus();
|
||||||
|
};
|
||||||
|
}, [active, containerRef]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBodyScrollLock(active: boolean) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, [active]);
|
||||||
|
}
|
||||||
@@ -5,6 +5,11 @@ metadata:
|
|||||||
namespace: tasteby
|
namespace: tasteby
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxSurge: 25%
|
||||||
|
maxUnavailable: 25%
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: backend
|
app: backend
|
||||||
@@ -34,11 +39,11 @@ spec:
|
|||||||
readOnly: true
|
readOnly: true
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 500m
|
cpu: 300m
|
||||||
memory: 768Mi
|
memory: 512Mi
|
||||||
limits:
|
limits:
|
||||||
cpu: "1"
|
cpu: 800m
|
||||||
memory: 1536Mi
|
memory: 1024Mi
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
tcpSocket:
|
tcpSocket:
|
||||||
port: 8000
|
port: 8000
|
||||||
|
|||||||
Reference in New Issue
Block a user