Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2eb16ce861 | ||
|
|
14384b0c71 | ||
|
|
250b067d87 | ||
|
|
6a885c5203 | ||
|
|
a9dc1dad6a | ||
|
|
21eb1e9562 | ||
|
|
94be5a81e6 | ||
|
|
1d767bee37 | ||
|
|
0676a31cfd | ||
|
|
1164139312 | ||
|
|
78f7e83a0e | ||
|
|
247547c516 | ||
|
|
8de8696424 | ||
|
|
a4de9ba87b | ||
|
|
cf37e496d4 | ||
|
|
ce3e34938c | ||
|
|
5199475d67 | ||
|
|
bd8d82dd5d | ||
|
|
bc83923261 | ||
|
|
f17ba9e37a | ||
|
|
7789671fbc | ||
|
|
c5b0216a37 | ||
|
|
40e448fe95 | ||
|
|
8a21646031 | ||
|
|
52090057de | ||
|
|
d73947444f | ||
|
|
c1050f3abd | ||
|
|
a504bf8ee5 | ||
|
|
f1164b63c5 | ||
|
|
47020fd649 | ||
|
|
88bbf3ca25 | ||
|
|
8152b71119 | ||
|
|
d6ee62230e | ||
|
|
cf1055bdf9 | ||
|
|
2580414790 | ||
|
|
730727a7a6 | ||
|
|
9ba905aad8 | ||
|
|
8c4b0c3e9a | ||
|
|
3815221535 | ||
|
|
49ef0322ac | ||
|
|
cc4bc0b7e4 | ||
|
|
515f5c1d1a | ||
|
|
6cbf7feaf5 | ||
|
|
fda2d76514 | ||
|
|
7d95ecb3cb | ||
|
|
7b2753b9fd | ||
|
|
7411c8956f | ||
|
|
be302612f5 | ||
|
|
91d9813253 | ||
|
|
11e1cf7877 | ||
|
|
648ccde4d7 | ||
|
|
ed61d29632 | ||
|
|
51f7b5c7d3 | ||
|
|
f4cb95e88c | ||
|
|
109ad106ac | ||
|
|
319fd18258 | ||
|
|
0fa58a622c | ||
|
|
9743f96af7 | ||
|
|
e5dc0534c4 | ||
|
|
c88cb6ad54 | ||
|
|
079384b645 | ||
|
|
c7bd3c4c09 | ||
|
|
1a5db34e15 | ||
|
|
f126664117 | ||
|
|
a0e8878d9a | ||
|
|
3304b9c54f | ||
|
|
437e709a8d | ||
|
|
dcebb9f06f | ||
|
|
bff3dcc200 | ||
|
|
ea8db4bef3 | ||
|
|
ed076411ed | ||
|
|
865cd86aff | ||
|
|
c6428e5d5f | ||
|
|
5579c5b00f | ||
|
|
4b02293046 | ||
|
|
eb1eaa91a6 | ||
|
|
9c2dc9f43a | ||
|
|
7779d5ddfd | ||
|
|
6ea82a5561 | ||
|
|
04c54d1b1a | ||
|
|
4407f2d67d | ||
|
|
7fa623d22d | ||
|
|
d2e78b0363 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -18,3 +18,9 @@ k8s/secrets.yaml
|
|||||||
# OS / misc
|
# OS / misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
backend/cookies.txt
|
backend/cookies.txt
|
||||||
|
backend-java/cookies.txt
|
||||||
|
**/cookies.txt
|
||||||
|
|
||||||
|
# 작업 산출물 (로컬 전용)
|
||||||
|
reviews/
|
||||||
|
screenshots/
|
||||||
|
|||||||
327
CHANGELOG.md
327
CHANGELOG.md
@@ -4,8 +4,335 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-06-17
|
||||||
|
|
||||||
|
### 🎯 클러스터 expansion 후 마커 클릭 시 재묶임 방지 (v0.1.66)
|
||||||
|
- 기존: selected 시 무조건 zoom 16 → 클러스터 expansion(18)에서 마커 클릭하면 다시 16으로 줄어 재묶임
|
||||||
|
- 변경: 현재 zoom ≥ 16이면 유지, 미만이면 16으로 (NaverMap morph + GoogleMap setZoom 조건부)
|
||||||
|
- page.tsx의 setRegionFlyTo 호출 제거 — selected useEffect로 일원화 (zoom 16 강제 회피)
|
||||||
|
|
||||||
|
### 🐛 NaverMap 클러스터 클릭 시 중앙 이동 fix (v0.1.65)
|
||||||
|
- panTo(애니메이션) + setZoom(z, true)(애니메이션) 두 애니메이션 동시 호출이 서로 cancel
|
||||||
|
- 단일 식당은 줌 변화 없어서 안 보였고, 클러스터는 줌 크게 변경 → 충돌 가시화
|
||||||
|
- NaverMap SDK의 `morph(latlng, zoom)` 한 메서드로 통일 — center+zoom 동시 변경
|
||||||
|
|
||||||
|
### 🎯 NaverMapView selected 자동 panTo + zoom (v0.1.64)
|
||||||
|
- 마커/클러스터/리스트 어디서 선택해도 그 식당이 화면 중앙으로 + zoom 16
|
||||||
|
- GoogleMapView에는 이미 있던 useEffect [selected] 패턴을 동일하게 추가
|
||||||
|
|
||||||
|
## 2026-06-16
|
||||||
|
|
||||||
|
### 🧹 미커밋 잡변경 정리 + 5개 분리 commit (v0.1.63)
|
||||||
|
- backend: 어드민 권한 토글 service/mapper/CORS PATCH 미커밋분 적용
|
||||||
|
- backend: JsonUtil.normalizeEvaluation 정식 commit (PipelineService/RestaurantService 이미 호출 중)
|
||||||
|
- frontend: Phosphor 아이콘 마이그레이션 + FoodIcon 신규 + brand-guide.md
|
||||||
|
- docs: oke-deploy-howto.md 신규
|
||||||
|
- chore: ecosystem PORT=3001 + reviews/screenshots gitignore
|
||||||
|
|
||||||
|
### 🎯 식당 선택 시 지도 자동 줌인/이동 (v0.1.62)
|
||||||
|
- 리스트 / 검색결과에서 식당 클릭 → setRegionFlyTo로 그 식당 좌표 + zoom 16
|
||||||
|
- 지도가 선택 식당으로 panTo + zoom — NaverMap/GoogleMap 둘 다
|
||||||
|
- 마커의 selected 강조(1.15× + 파란 박스)와 함께 동작
|
||||||
|
|
||||||
|
### 🏷️ NaverMapView 마커에 식당명 박스 (v0.1.61)
|
||||||
|
- 단순 동그라미 → GoogleMapView와 동일 핀 디자인(박스+화살표+식당명+cuisine 아이콘)
|
||||||
|
- 채널별 배경/테두리/화살표 색상, 폐업(business_status CLOSED_*) 표시 회색 + 취소선
|
||||||
|
- selected 식당 강조 (1.15× scale + 파란 박스), zIndex 1000
|
||||||
|
- InfoWindow 제거 (식당명 자체가 박스로 보이므로 불필요)
|
||||||
|
|
||||||
|
### 🎨 NaverMapView 채널별 마커 색상 (v0.1.60)
|
||||||
|
- GoogleMapView와 동일 팔레트 (amber/blue/green/pink/purple/red/teal/yellow)
|
||||||
|
- 식당의 첫 채널 기준 색상, activeChannel 있으면 그 채널 우선
|
||||||
|
|
||||||
|
### ⚡ NaverMapView SDK 네이티브 마커 + InfoWindow (v0.1.59)
|
||||||
|
- 마커를 React `absolute div` overlay → `naver.maps.Marker` 네이티브로 교체
|
||||||
|
- 줌/팬 시 SDK가 GPU 최적화, 매 frame React 리렌더링 없음 → 랙 해소
|
||||||
|
- 식당명 InfoWindow 추가 (마커 클릭 시 표시)
|
||||||
|
- bounds_changed → idle 이벤트로 sync (줌/팬 중 발화 빈도 ↓)
|
||||||
|
- 클러스터도 네이티브 마커 (HTML 콘텐츠로 숫자 표시)
|
||||||
|
|
||||||
|
### 🗺️ NaverMapView 안정화 + 재활성 (v0.1.57)
|
||||||
|
- divRef 항상 마운트 (이전: ready 가드로 첫 렌더 ref 누락 가능)
|
||||||
|
- 명시적 width/height + 회색 배경(시각적 로딩 표시)
|
||||||
|
- ResizeObserver + requestAnimationFrame으로 컨테이너 0×0 → 정상 크기 시 refresh
|
||||||
|
- try/catch + initError state로 init 실패 가시화
|
||||||
|
- Naver 키 재활성
|
||||||
|
|
||||||
|
### ⏪ NaverMap 임시 비활성, 한국도 GoogleMap fallback (v0.1.55)
|
||||||
|
- NaverMapView 골격이 실 운영에서 지도/마커 렌더 실패 (정확한 원인 추후 진단)
|
||||||
|
- NEXT_PUBLIC_NAVER_MAP_CLIENT_ID 빈 값으로 dispatcher가 GoogleMap fallback (회귀 0)
|
||||||
|
- NaverMapView 코드는 유지 — 안정화 후 환경변수 채우면 재활성
|
||||||
|
|
||||||
|
### 🐛 /api/stats/visits 500 — StatsMapper resultType int → long (v0.1.54)
|
||||||
|
- StatsMapper interface는 `long` 반환인데 XML resultType이 `int` → Integer를 long에 cast 실패
|
||||||
|
- ClassCastException: Integer → Long. resultType만 long으로 교정
|
||||||
|
|
||||||
|
### 🐛 NaverMap 인증 파라미터 ncpClientId → ncpKeyId (v0.1.53)
|
||||||
|
- NCLOUD 신 정책: `ncpKeyId` 사용 (옛 `ncpClientId`는 NAVER Developers용)
|
||||||
|
- 인증 200/Failed의 진짜 원인 — 도메인 등록은 정확했으나 파라미터 이름 차이로 키 인식 실패
|
||||||
|
- 새 NCLOUD Maps Client ID(`fg01bipxbo`)로 prod 재빌드
|
||||||
|
- 참고: https://github.com/navermaps/maps.js.ncp/blob/master/index.html
|
||||||
|
|
||||||
|
### 🗺️ #363 메인 지도 SDK 국내(네이버)/해외(구글) 분기 (v0.1.52)
|
||||||
|
- MapView를 dispatcher로 전환: 좌표가 KR bbox + NAVER_MAP_CLIENT_ID 설정 시 NaverMapView, 그 외 GoogleMapView
|
||||||
|
- NaverMapView 신규 (네이버 v3 직접 wrapper, Supercluster 재사용, 마커/클러스터/flyTo)
|
||||||
|
- GoogleMapView 신규 (기존 MapView 내용 rename)
|
||||||
|
- MapView.types.ts 공용 (MapBounds/FlyTo/MapViewProps + isKoreaCoord)
|
||||||
|
- Dockerfile + deploy.sh: NEXT_PUBLIC_NAVER_MAP_CLIENT_ID build-arg 추가
|
||||||
|
- 키 미설정 시 GoogleMap fallback (회귀 0)
|
||||||
|
- 설계서: docs/design/363-map-sdk-branch/README.md
|
||||||
|
- Refs: #363
|
||||||
|
|
||||||
|
### 🗺️ 식당 상세 지도 링크 국내/해외 분기 (v0.1.51)
|
||||||
|
- 좌표 기반 한국 판정 (WGS84 KR bbox 33~38.7°N, 124~132°E)
|
||||||
|
- 국내: 네이버 지도 primary + Google Maps 보조 (네이버 URL은 신 도메인 /p/search/)
|
||||||
|
- 해외: Google Maps 단독
|
||||||
|
- 좌표 없으면 region 첫 토큰 fallback (구 데이터 호환)
|
||||||
|
- frontend-only 배포
|
||||||
|
|
||||||
## 2026-06-15
|
## 2026-06-15
|
||||||
|
|
||||||
|
### 🐛 캐치테이블 URL 패턴 수정 (v0.1.50)
|
||||||
|
- 실제 catchtable URL은 `app.catchtable.co.kr/ct/shop/...` 형식 (옛 `/shop/`, `/dining/`은 매칭 실패)
|
||||||
|
- 첫 회차(v0.1.49) 캐치테이블 벌크 결과 1044건 전부 미발견(매핑 0%)의 원인
|
||||||
|
- 패턴을 `catchtable.co.kr/ct/shop/`, `catchtable.co.kr/ct/dining/`로 교정 후 NONE 해제 + 재실행
|
||||||
|
|
||||||
|
### 🐛 WebSearchService HTTP timeout 추가 (v0.1.49)
|
||||||
|
- 벌크 백필 중 특정 검색에서 무한 hang → backend executor virtual thread 점유로 후속 작업 중단 (90건 처리 후 멈춤)
|
||||||
|
- connectTimeout=5s + request timeout=15s (Naver/DDG 둘 다)
|
||||||
|
- 해당 식당은 HttpTimeoutException → notfound로 안전 처리
|
||||||
|
|
||||||
|
### ⏱️ bulk-tabling/catchtable SSE timeout 10분 → 3시간 (v0.1.48)
|
||||||
|
- 대량 백필(724건 ≈ 100분) 시 10분 SSE timeout으로 중간 끊김 → 3시간으로 확장
|
||||||
|
- 백엔드 작업은 virtual thread로 별도 진행됐지만 emit() 예외로 마지막 cache.flush + complete 누락이슈 해소
|
||||||
|
|
||||||
|
### 🐛 #357 후속 — tabling-url validation에 www. 호스트 허용 (v0.1.47)
|
||||||
|
- Naver/DDG 결과가 `https://www.tabling.co.kr/...` 형태인데 #290 validation은 `tabling.co.kr/`만 허용 → 단건 매핑 PUT 거부
|
||||||
|
- bulk-tabling SSE는 validation 없이 service.update 직접 호출이라 통과 → 단일/벌크 불일치
|
||||||
|
- `www.tabling.co.kr` prefix도 허용 (catchtable은 이미 app/www 둘 다 허용)
|
||||||
|
- 시연 등록: bbq 부천은하마을점 → BBQ 치킨 부천은하마을점
|
||||||
|
|
||||||
|
### 🔍 #359 1단계 — google_place_id 중복 조회 API (v0.1.46)
|
||||||
|
- GET /api/admin/restaurants/duplicates/place-id (어드민 전용)
|
||||||
|
- 응답: 그룹별 식당 + video/review/memo 카운트 (병합 의사결정 자료)
|
||||||
|
- 정리/병합 + UNIQUE 제약은 별도 PR (데이터 위험 분리)
|
||||||
|
- 설계서: docs/design/359a-duplicate-place-id-view/README.md
|
||||||
|
- Refs: #359 (조회 단계 완료, 후속 분리 유지)
|
||||||
|
|
||||||
|
### 📋 #358 RestaurantUpdateDTO + @Valid 표준화 (v0.1.45)
|
||||||
|
- dto/RestaurantUpdateDTO record 신규 (15 필드, 모두 nullable)
|
||||||
|
- Bean Validation: @Size/@Pattern(URL or NONE)/@DecimalMin·Max/@Min·Max
|
||||||
|
- RestaurantController.update 시그니처 Map → @Valid DTO 교체
|
||||||
|
- toFieldMap()으로 null 제외 후 기존 Service.update 호출 (회귀 0)
|
||||||
|
- #332 ALLOWED_UPDATE_FIELDS Set 제거 (DTO 필드 자체가 화이트리스트)
|
||||||
|
- 설계서: docs/design/358-restaurant-update-dto/README.md
|
||||||
|
- Refs: #358 (close)
|
||||||
|
|
||||||
|
### 🔎 #357 DDG → Naver Search 정식 API + DDG 폴백 (v0.1.44)
|
||||||
|
- WebSearchService 신규 (Naver webkr.json 우선, 키 미설정/실패 시 DDG 폴백)
|
||||||
|
- RestaurantController.searchTabling/searchCatchtable 내부 호출 교체, DDG 인라인 80줄 제거
|
||||||
|
- application.yml: app.naver.client-id/secret (NAVER_CLIENT_ID/SECRET 환경변수)
|
||||||
|
- k8s/secrets.yaml.template에 NAVER_CLIENT_ID/SECRET 항목 추가
|
||||||
|
- 미사용 import 정리 (HttpClient/URI/URLEncoder/Pattern 등 RestaurantController에서)
|
||||||
|
- 설계서: docs/design/357-web-search-api/README.md
|
||||||
|
- Refs: #357 (close)
|
||||||
|
|
||||||
|
### 🎯 #356 영상-식당 관련도 LLM 평가 (v0.1.43)
|
||||||
|
- DB: video_restaurants 컬럼 추가 (relevance/relevance_reason/relevance_evaluated_at) + idx_vr_relevance
|
||||||
|
- VideoRelevanceService 신규 (#322 RestaurantVerifyService 패턴 모방, @Async verifyAsync/verify/verifyAll)
|
||||||
|
- PipelineService.processExtract — linkVideoRestaurant 후 verifyAsync(linkId) 자동 트리거
|
||||||
|
- GET /api/restaurants/{id}/videos: 기본 strong/unknown만 응답 (안전 기본값), ?include_weak=true 시 모두
|
||||||
|
- AdminVideoRelevanceController 신규 (pending/all/{id}/evaluate/{id} PATCH)
|
||||||
|
- 응답 매핑: relevance, relevance_reason 필드 동봉
|
||||||
|
- 기존 1244 링크는 'unknown' 시작 → 어드민 백필로 점진 평가
|
||||||
|
- 설계서: docs/design/356-video-relevance-llm/README.md
|
||||||
|
- Refs: #356 (close)
|
||||||
|
|
||||||
|
### 🧹 #351 admin SSE 6곳 consumeSseStream 통일 (v0.1.42)
|
||||||
|
- VideosPanel 4곳(bulkTranscript/Extract, rebuildVectors, remapCuisine, remapFoods)
|
||||||
|
- RestaurantsPanel 2곳(bulkTabling, bulkCatchtable)
|
||||||
|
- response.body?.getReader 직접 호출 0건 (lib/admin-utils.ts의 consumeSseStream 활용)
|
||||||
|
- 149줄 삭제 → 74줄 압축, npm test 13/13 통과
|
||||||
|
- Refs: #351 (close)
|
||||||
|
|
||||||
|
### 🧪 #343 Jest+RTL 인프라 + ARIA Tabs + remotePatterns (v0.1.40)
|
||||||
|
- Jest 30 + jest-environment-jsdom + RTL + jest-dom matchers 도입
|
||||||
|
- next/jest 자동 SWC 통합, jest.config.ts + jest.setup.ts (setupFilesAfterEnv)
|
||||||
|
- npm scripts: test, test:watch
|
||||||
|
- 샘플 테스트 3개 13/13 통과: i18n/config(5), Stars(5), admin-utils(4)
|
||||||
|
- MyReviewsList: role=tablist/tab/aria-selected/aria-controls/tabIndex + tabpanel
|
||||||
|
- next.config.ts remotePatterns: Google avatar + YouTube thumbnail/avatar
|
||||||
|
- 후속: 전체 컴포넌트 테스트 확장, 백엔드 JUnit, E2E(Playwright), CI 통합
|
||||||
|
- 설계서: docs/design/343-frontend-test-infra/README.md
|
||||||
|
- Refs: #343 (close)
|
||||||
|
|
||||||
|
### 🔤 #348 isNameSimilar 한국어 자모 + Sørensen-Dice (v0.1.38)
|
||||||
|
- HangulSimilarity 유틸 신규 (Unicode NFD 분해 + bigram Sørensen-Dice)
|
||||||
|
- RestaurantController.isNameSimilar 교체, 임계값 0.45
|
||||||
|
- 짧은 한국어 이름 매칭 정확도 향상 (예: "스타벅스 강남" vs "스타벅스 강남점")
|
||||||
|
- 후속 분리: #357(DDG→정식 API), #358(DTO+@Valid), #359(UNIQUE+데이터 정리)
|
||||||
|
- 설계서: docs/design/348-name-similarity/README.md
|
||||||
|
- Refs: #348 (close)
|
||||||
|
|
||||||
|
### 🌐 #352 i18n 뼈대 ko/en/ja/es (v0.1.37)
|
||||||
|
- next-intl 5.x 도입
|
||||||
|
- src/i18n/{config,LocaleProvider} + src/messages/{ko,en,ja,es}.json (30 키)
|
||||||
|
- LanguageSwitcher 컴포넌트 (헤더, ARIA listbox, 44px, 국기+네이티브명)
|
||||||
|
- localStorage tasteby_locale + 브라우저 언어 감지 + ko fallback
|
||||||
|
- 설계서: docs/design/352-i18n-skeleton/README.md
|
||||||
|
- 미적용: URL 라우팅 i18n, SEO meta, 사용자 콘텐츠 번역, 어드민(한국어 유지)
|
||||||
|
- Refs: #352 (close)
|
||||||
|
|
||||||
|
### 🧹 #329 admin/page.tsx 분리 (v0.1.35→v0.1.36 운영 반영)
|
||||||
|
- page.tsx 2817 → 107 LOC (탭 라우팅 + 헤더만)
|
||||||
|
- _panels/{Channels,Videos,Restaurants,Users,Daemon}Panel.tsx 5개 분리
|
||||||
|
- localStorage.getItem 10곳 → getAdminToken() (admin-utils.ts)
|
||||||
|
- SSE 통일은 후속 #351 분리
|
||||||
|
- 설계서: docs/design/329-admin-split/README.md
|
||||||
|
- Refs: #329 (close)
|
||||||
|
|
||||||
|
### ⚡ #331 VectorService batchUpdate (v0.1.34)
|
||||||
|
- saveRestaurantVectors: N+1 단건 INSERT → 단일 jdbc.batchUpdate(SqlParameterSource[])
|
||||||
|
- UUID 인라인 변환 제거 → IdGenerator.newId() 공통화
|
||||||
|
- 현재 N=1이지만 chunk 분할 도입 시 효과 본격화
|
||||||
|
- 설계서: docs/design/331-vector-batch-insert/README.md
|
||||||
|
- Refs: #331 (close)
|
||||||
|
|
||||||
|
### ⚡ #326 parseJson 단일 패스 (v0.1.33)
|
||||||
|
- OciGenAiService.parseJson 잘린 배열 복구를 brace depth counter 단일 패스로 교체
|
||||||
|
- 이전 O(N²) + Jackson 예외 양산 → O(N) + 명시적 에러 경로
|
||||||
|
- 문자열/escape 처리 정확
|
||||||
|
- 설계서: docs/design/326-parsejson-optimization/README.md
|
||||||
|
- Refs: #326 (close)
|
||||||
|
|
||||||
|
### 🛡️ #332 Restaurant PUT 화이트리스트 명시 (v0.1.32)
|
||||||
|
- ALLOWED_UPDATE_FIELDS set으로 PUT /api/restaurants/{id} body 필터
|
||||||
|
- 허용 외 키 silent drop + DEBUG 로그
|
||||||
|
- sanitized.isEmpty()면 200 + no-op
|
||||||
|
- 후속 분리: #348 (DDG → 정식 API, isNameSimilar 한국어, DTO 표준화)
|
||||||
|
- Refs: #332 (close)
|
||||||
|
|
||||||
|
### 🛡️ #337 통계 봇 필터 + 레이트리밋 (v0.1.31)
|
||||||
|
- BotDetector: UA 정규식 (bot|crawler|spider|slurp|scrap|fetch|monitor|preview|lighthouse)
|
||||||
|
- RateLimitService: Redis SET NX EX(60s) 패턴, fail-open (의존성 최소화)
|
||||||
|
- StatsController.recordVisit: X-Forwarded-For 우선 IP + 봇/IP 가드
|
||||||
|
- 응답: {ok, counted:bool} — 차단도 200 (사용자 페이지 지장 X)
|
||||||
|
- application.yml: app.rate-limit.visit-window-seconds (기본 60)
|
||||||
|
- 운영 검증: Googlebot/Mozilla/즉시 재호출 인수조건 모두 충족
|
||||||
|
- 설계서: docs/design/337-stats-bot-ratelimit/README.md
|
||||||
|
- Refs: #337 (close)
|
||||||
|
|
||||||
|
### 🔒 #335 데몬 분산 락 ShedLock+Redis (v0.1.30)
|
||||||
|
- shedlock-spring 5.16.0 + shedlock-provider-redis-spring
|
||||||
|
- @EnableSchedulerLock(defaultLockAtMostFor=PT15M)
|
||||||
|
- DaemonScheduler.run: @SchedulerLock(name="daemon-runner", lockAtMostFor=PT15M, lockAtLeastFor=PT30S)
|
||||||
|
- ShedLockConfig: RedisLockProvider Bean (in-cluster Redis 재사용)
|
||||||
|
- 멀티 파드(RollingUpdate) + dev/prod ATP 공유 환경에서 데몬 중복 실행 차단
|
||||||
|
- 설계서: docs/design/335-daemon-distributed-lock/README.md
|
||||||
|
- Refs: #335 (close)
|
||||||
|
|
||||||
|
### 💾 #336 캐시 SCAN/UNLINK + 자동 복구 + 에러 메트릭 (v0.1.29)
|
||||||
|
- CacheService.flush: redis.keys() 블로킹 → SCAN cursor + UNLINK 논블로킹 (500 batch)
|
||||||
|
- @Scheduled(30s) checkHealth: Redis ping → disabled 자동 토글 (재기동 시 자동 복구)
|
||||||
|
- AtomicLong errorCount + volatile lastError + 로그 throttle (n==1 또는 n%100==0)
|
||||||
|
- GET /api/admin/cache/stats: disabled/errorCount/lastError 노출 (admin only)
|
||||||
|
- 설계서: docs/design/336-cache-scan-recovery/README.md
|
||||||
|
- Refs: #336 (close)
|
||||||
|
|
||||||
|
### 🔧 P5-2 작은 후속 (v0.1.26)
|
||||||
|
- #338: /api/version 신규 (HealthController + permitAll), application.yml app.build.{version,commit} env 주입 준비
|
||||||
|
- #320: findRegionFromCoords 거리 보정 (유클리드 → cos(lat) 가중치)
|
||||||
|
- #340: MapView 클러스터/마커/범례에 role/aria-label
|
||||||
|
- #333: ChannelController cache.flush() → cache.del("channels") (다른 모듈 캐시 보존)
|
||||||
|
- Refs: #338 #320 #340 #333 (close)
|
||||||
|
|
||||||
|
### 🧹 P5-1 작은 후속 묶음 (v0.1.24)
|
||||||
|
- #325: ThreadLocalRandom 통일, rebuildVectors not_implemented 이벤트, getTranscript JavaDoc 명세
|
||||||
|
- #319: buildSearchQuery 헬퍼 + fn-doc(BottomSheet snap 정책)
|
||||||
|
- #344: --z-bottom-sheet/--z-filter-sheet/--z-modal CSS 변수 + LoginMenu zIndex 99999 → var(--z-modal)
|
||||||
|
- Refs: #319 #325 #344 (close)
|
||||||
|
|
||||||
|
### ⭐ 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)
|
### ♿ #301+#302 모달 접근성 + race condition + 필터 상태 동기화 (v0.1.14)
|
||||||
- 공통 훅 `frontend/src/lib/hooks/useModalA11y.ts` 신규 (useEscapeKey, useFocusTrap, useBodyScrollLock)
|
- 공통 훅 `frontend/src/lib/hooks/useModalA11y.ts` 신규 (useEscapeKey, useFocusTrap, useBodyScrollLock)
|
||||||
- BottomSheet/FilterSheet: role='dialog', aria-modal, aria-label/labelledby, ESC 닫기, focus trap
|
- BottomSheet/FilterSheet: role='dialog', aria-modal, aria-label/labelledby, ESC 닫기, focus trap
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ dependencies {
|
|||||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
|
|
||||||
|
// #335 — 분산 락 (RollingUpdate 시 멀티 파드 공존 중 데몬 중복 실행 차단)
|
||||||
|
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.16.0'
|
||||||
|
implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.16.0'
|
||||||
|
|
||||||
|
// #337 — IP 레이트리밋은 Redis SET NX EX 패턴으로 자체 구현 (기존 spring-data-redis 활용)
|
||||||
|
|
||||||
// Oracle JDBC + Security (Wallet support for Oracle ADB)
|
// Oracle JDBC + Security (Wallet support for Oracle ADB)
|
||||||
implementation 'com.oracle.database.jdbc:ojdbc11:23.7.0.25.01'
|
implementation 'com.oracle.database.jdbc:ojdbc11:23.7.0.25.01'
|
||||||
implementation 'com.oracle.database.security:oraclepki:23.7.0.25.01'
|
implementation 'com.oracle.database.security:oraclepki:23.7.0.25.01'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.tasteby;
|
package com.tasteby;
|
||||||
|
|
||||||
|
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
@@ -8,6 +9,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
|
// #335 — defaultLockAtMostFor: 어떤 작업이 lockAtMostFor 명시 안 해도 보호 (안전 마진)
|
||||||
|
@EnableSchedulerLock(defaultLockAtMostFor = "PT15M")
|
||||||
public class TastebyApplication {
|
public class TastebyApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(TastebyApplication.class, args);
|
SpringApplication.run(TastebyApplication.class, args);
|
||||||
|
|||||||
@@ -30,13 +30,14 @@ public class SecurityConfig {
|
|||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
// Public endpoints
|
// Public endpoints
|
||||||
.requestMatchers("/api/health").permitAll()
|
.requestMatchers("/api/health").permitAll()
|
||||||
|
.requestMatchers("/api/version").permitAll() // #338 — 빌드 정보 공개
|
||||||
.requestMatchers("/api/auth/**").permitAll()
|
.requestMatchers("/api/auth/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/restaurants/**").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/restaurants/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/channels").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/channels").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/search").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/search").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/restaurants/*/reviews").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/restaurants/*/reviews").permitAll()
|
||||||
.requestMatchers("/api/stats/**").permitAll()
|
.requestMatchers("/api/stats/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/daemon/config").permitAll()
|
// #275 — /api/daemon/config는 admin-only로 변경 (이전 permitAll 제거)
|
||||||
// Everything else requires authentication (controller-level admin checks)
|
// Everything else requires authentication (controller-level admin checks)
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.tasteby.config;
|
||||||
|
|
||||||
|
import net.javacrumbs.shedlock.core.LockProvider;
|
||||||
|
import net.javacrumbs.shedlock.provider.redis.spring.RedisLockProvider;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #335 — ShedLock LockProvider (Redis 기반).
|
||||||
|
*
|
||||||
|
* 데몬 스케줄러가 다중 파드 환경에서 한 번에 하나만 실행되도록 보장.
|
||||||
|
* key prefix는 ShedLock 기본 ("lock:")을 사용 → Redis 키는 `lock:daemon-runner`.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class ShedLockConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
|
||||||
|
return new RedisLockProvider(connectionFactory);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ public class WebConfig implements WebMvcConfigurer {
|
|||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
CorsConfiguration config = new CorsConfiguration();
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
config.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
|
config.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
|
||||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||||
config.setAllowedHeaders(List.of("*"));
|
config.setAllowedHeaders(List.of("*"));
|
||||||
config.setAllowCredentials(true);
|
config.setAllowCredentials(true);
|
||||||
|
|
||||||
|
|||||||
@@ -22,4 +22,14 @@ public class AdminCacheController {
|
|||||||
cacheService.flush();
|
cacheService.flush();
|
||||||
return Map.of("ok", true);
|
return Map.of("ok", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #336 — 캐시 상태 가시화: disabled / errorCount / lastError.
|
||||||
|
* 외부 모니터링 도구 도입 전 운영자가 어드민에서 확인 가능.
|
||||||
|
*/
|
||||||
|
@GetMapping("/cache/stats")
|
||||||
|
public CacheService.CacheStats cacheStats() {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
return cacheService.getStats();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #359 1단계 — google_place_id 중복 조회 (정리/UNIQUE는 후속).
|
||||||
|
@GetMapping("/duplicates/place-id")
|
||||||
|
public Map<String, Object> duplicatePlaceIds() {
|
||||||
|
var admin = AuthUtil.requireAdmin();
|
||||||
|
var groups = restaurantService.findDuplicatePlaceIdGroups();
|
||||||
|
log.info("[ADMIN] {} duplicate place_id groups: {}", admin.getSubject(), groups.size());
|
||||||
|
return Map.of("groups", groups, "group_count", groups.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 어드민용 hidden 토글.
|
||||||
|
*/
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.tasteby.controller;
|
||||||
|
|
||||||
|
import com.tasteby.security.AuthUtil;
|
||||||
|
import com.tasteby.service.RestaurantService;
|
||||||
|
import com.tasteby.service.VideoRelevanceService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #356 영상-식당 관련도 LLM 평가 어드민 API.
|
||||||
|
* - 미평가 카운트 / 일괄 백필 / 단건 재평가 / 수동 토글
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/video-relevance")
|
||||||
|
public class AdminVideoRelevanceController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AdminVideoRelevanceController.class);
|
||||||
|
private static final Set<String> VALID = Set.of("strong", "weak", "incidental", "unknown");
|
||||||
|
|
||||||
|
private final RestaurantService restaurantService;
|
||||||
|
private final VideoRelevanceService relevanceService;
|
||||||
|
|
||||||
|
public AdminVideoRelevanceController(RestaurantService restaurantService, VideoRelevanceService relevanceService) {
|
||||||
|
this.restaurantService = restaurantService;
|
||||||
|
this.relevanceService = relevanceService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/pending")
|
||||||
|
public Map<String, Object> pendingCount() {
|
||||||
|
var admin = AuthUtil.requireAdmin();
|
||||||
|
int n = restaurantService.countUnevaluatedLinks();
|
||||||
|
log.info("[ADMIN] {} video-relevance pending: {}", admin.getSubject(), n);
|
||||||
|
return Map.of("pending", n);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/all")
|
||||||
|
public Map<String, Object> verifyAll(@RequestParam(defaultValue = "10") int batchSize) {
|
||||||
|
var admin = AuthUtil.requireAdmin();
|
||||||
|
int pending = restaurantService.countUnevaluatedLinks();
|
||||||
|
log.info("[ADMIN] {} triggered video-relevance verifyAllAsync (batchSize={}, pending={})", admin.getSubject(), batchSize, pending);
|
||||||
|
// 비동기 트리거 — HTTP request는 즉시 응답. 진행은 /pending 폴링으로 확인.
|
||||||
|
relevanceService.verifyAllAsync(batchSize);
|
||||||
|
return Map.of("started", true, "pending", pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{linkId}/evaluate")
|
||||||
|
public Map<String, Object> evaluateOne(@PathVariable String linkId) {
|
||||||
|
var admin = AuthUtil.requireAdmin();
|
||||||
|
log.info("[ADMIN] {} video-relevance evaluate({})", admin.getSubject(), linkId);
|
||||||
|
relevanceService.verify(linkId);
|
||||||
|
return Map.of("success", true, "linkId", linkId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{linkId}")
|
||||||
|
public Map<String, Object> setRelevance(@PathVariable String linkId, @RequestBody Map<String, Object> body) {
|
||||||
|
var admin = AuthUtil.requireAdmin();
|
||||||
|
Object relObj = body.get("relevance");
|
||||||
|
if (!(relObj instanceof String relevance) || !VALID.contains(relevance)) {
|
||||||
|
return Map.of("success", false, "error", "relevance must be one of strong|weak|incidental|unknown");
|
||||||
|
}
|
||||||
|
String reason = body.get("reason") instanceof String s ? s : "manual";
|
||||||
|
restaurantService.updateLinkRelevance(linkId, relevance, reason);
|
||||||
|
log.info("[ADMIN] {} manual relevance={} for link {}", admin.getSubject(), relevance, linkId);
|
||||||
|
return Map.of("success", true, "linkId", linkId, "relevance", relevance);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,15 +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();
|
// #333 — 전체 flush 대신 channels 키만 evict (다른 모듈 캐시 보존)
|
||||||
|
cache.del(cache.makeKey("channels"));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.tasteby.controller;
|
package com.tasteby.controller;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
@@ -8,8 +9,20 @@ import java.util.Map;
|
|||||||
@RestController
|
@RestController
|
||||||
public class HealthController {
|
public class HealthController {
|
||||||
|
|
||||||
|
// #338 — 배포 시 set되는 빌드 정보. 미설정 시 "dev"로 표시.
|
||||||
|
@Value("${app.build.version:dev}")
|
||||||
|
private String version;
|
||||||
|
|
||||||
|
@Value("${app.build.commit:unknown}")
|
||||||
|
private String commit;
|
||||||
|
|
||||||
@GetMapping("/api/health")
|
@GetMapping("/api/health")
|
||||||
public Map<String, String> health() {
|
public Map<String, String> health() {
|
||||||
return Map.of("status", "ok");
|
return Map.of("status", "ok");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/version")
|
||||||
|
public Map<String, String> version() {
|
||||||
|
return Map.of("version", version, "commit", commit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.tasteby.domain.Restaurant;
|
import com.tasteby.domain.Restaurant;
|
||||||
import com.tasteby.security.AuthUtil;
|
import com.tasteby.security.AuthUtil;
|
||||||
|
import com.tasteby.dto.RestaurantUpdateDTO;
|
||||||
import com.tasteby.service.CacheService;
|
import com.tasteby.service.CacheService;
|
||||||
import com.tasteby.service.GeocodingService;
|
import com.tasteby.service.GeocodingService;
|
||||||
import com.tasteby.service.RestaurantService;
|
import com.tasteby.service.RestaurantService;
|
||||||
|
import com.tasteby.service.WebSearchService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
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;
|
||||||
@@ -14,19 +18,10 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URLDecoder;
|
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.ThreadLocalRandom;
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/restaurants")
|
@RequestMapping("/api/restaurants")
|
||||||
@@ -38,13 +33,21 @@ public class RestaurantController {
|
|||||||
private final GeocodingService geocodingService;
|
private final GeocodingService geocodingService;
|
||||||
private final CacheService cache;
|
private final CacheService cache;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final WebSearchService webSearch;
|
||||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||||
|
|
||||||
public RestaurantController(RestaurantService restaurantService, GeocodingService geocodingService, CacheService cache, ObjectMapper objectMapper) {
|
public RestaurantController(RestaurantService restaurantService, GeocodingService geocodingService, CacheService cache, ObjectMapper objectMapper, WebSearchService webSearch) {
|
||||||
this.restaurantService = restaurantService;
|
this.restaurantService = restaurantService;
|
||||||
this.geocodingService = geocodingService;
|
this.geocodingService = geocodingService;
|
||||||
this.cache = cache;
|
this.cache = cache;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.webSearch = webSearch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #290 — Bean 종료 시 virtual thread executor를 정리하여 리소스 누수 방지.
|
||||||
|
@PreDestroy
|
||||||
|
public void shutdownExecutor() {
|
||||||
|
executor.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -61,7 +64,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 +78,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");
|
||||||
@@ -84,14 +87,17 @@ public class RestaurantController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
|
public Map<String, Object> update(@PathVariable String id, @Valid @RequestBody RestaurantUpdateDTO dto) {
|
||||||
AuthUtil.requireAdmin();
|
AuthUtil.requireAdmin();
|
||||||
var r = restaurantService.findById(id);
|
var r = restaurantService.findById(id);
|
||||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||||
|
|
||||||
|
// #358 — DTO → Map (null 제외). 화이트리스트는 DTO 필드 자체로 표현.
|
||||||
|
Map<String, Object> sanitized = dto.toFieldMap();
|
||||||
|
|
||||||
// Re-geocode if name or address changed
|
// Re-geocode if name or address changed
|
||||||
String newName = (String) body.get("name");
|
String newName = (String) sanitized.get("name");
|
||||||
String newAddress = (String) body.get("address");
|
String newAddress = (String) sanitized.get("address");
|
||||||
boolean nameChanged = newName != null && !newName.equals(r.getName());
|
boolean nameChanged = newName != null && !newName.equals(r.getName());
|
||||||
boolean addressChanged = newAddress != null && !newAddress.equals(r.getAddress());
|
boolean addressChanged = newAddress != null && !newAddress.equals(r.getAddress());
|
||||||
if (nameChanged || addressChanged) {
|
if (nameChanged || addressChanged) {
|
||||||
@@ -99,26 +105,30 @@ public class RestaurantController {
|
|||||||
String geoAddr = newAddress != null ? newAddress : r.getAddress();
|
String geoAddr = newAddress != null ? newAddress : r.getAddress();
|
||||||
var geo = geocodingService.geocodeRestaurant(geoName, geoAddr);
|
var geo = geocodingService.geocodeRestaurant(geoName, geoAddr);
|
||||||
if (geo != null) {
|
if (geo != null) {
|
||||||
body.put("latitude", geo.get("latitude"));
|
sanitized.put("latitude", geo.get("latitude"));
|
||||||
body.put("longitude", geo.get("longitude"));
|
sanitized.put("longitude", geo.get("longitude"));
|
||||||
body.put("google_place_id", geo.get("google_place_id"));
|
sanitized.put("google_place_id", geo.get("google_place_id"));
|
||||||
if (geo.containsKey("formatted_address")) {
|
if (geo.containsKey("formatted_address")) {
|
||||||
body.put("address", geo.get("formatted_address"));
|
sanitized.put("address", geo.get("formatted_address"));
|
||||||
}
|
}
|
||||||
if (geo.containsKey("rating")) body.put("rating", geo.get("rating"));
|
if (geo.containsKey("rating")) sanitized.put("rating", geo.get("rating"));
|
||||||
if (geo.containsKey("rating_count")) body.put("rating_count", geo.get("rating_count"));
|
if (geo.containsKey("rating_count")) sanitized.put("rating_count", geo.get("rating_count"));
|
||||||
if (geo.containsKey("phone")) body.put("phone", geo.get("phone"));
|
if (geo.containsKey("phone")) sanitized.put("phone", geo.get("phone"));
|
||||||
if (geo.containsKey("business_status")) body.put("business_status", geo.get("business_status"));
|
if (geo.containsKey("business_status")) sanitized.put("business_status", geo.get("business_status"));
|
||||||
|
|
||||||
// formatted_address에서 region 파싱 (예: "대한민국 서울특별시 강남구 ..." → "한국|서울|강남구")
|
|
||||||
String addr = (String) geo.get("formatted_address");
|
String addr = (String) geo.get("formatted_address");
|
||||||
if (addr != null) {
|
if (addr != null) {
|
||||||
body.put("region", GeocodingService.parseRegionFromAddress(addr));
|
sanitized.put("region", GeocodingService.parseRegionFromAddress(addr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
restaurantService.update(id, body);
|
if (sanitized.isEmpty()) {
|
||||||
|
// 허용 키가 하나도 없으면 no-op
|
||||||
|
return Map.of("ok", true, "restaurant", r);
|
||||||
|
}
|
||||||
|
|
||||||
|
restaurantService.update(id, sanitized);
|
||||||
cache.flush();
|
cache.flush();
|
||||||
var updated = restaurantService.findById(id);
|
var updated = restaurantService.findById(id);
|
||||||
return Map.of("ok", true, "restaurant", updated);
|
return Map.of("ok", true, "restaurant", updated);
|
||||||
@@ -165,7 +175,7 @@ public class RestaurantController {
|
|||||||
@PostMapping("/bulk-tabling")
|
@PostMapping("/bulk-tabling")
|
||||||
public SseEmitter bulkTabling() {
|
public SseEmitter bulkTabling() {
|
||||||
AuthUtil.requireAdmin();
|
AuthUtil.requireAdmin();
|
||||||
SseEmitter emitter = new SseEmitter(600_000L);
|
SseEmitter emitter = new SseEmitter(10_800_000L); // 3h — 대량 백필 대응
|
||||||
|
|
||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
try {
|
try {
|
||||||
@@ -241,6 +251,13 @@ 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 차단. 빈 문자열은 매핑 해제로 허용.
|
||||||
|
// Naver/DDG 결과가 www.tabling.co.kr 형태로도 옴.
|
||||||
|
if (url != null && !url.isBlank()
|
||||||
|
&& !url.startsWith("https://tabling.co.kr/")
|
||||||
|
&& !url.startsWith("https://www.tabling.co.kr/")) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "테이블링 URL은 https://(www.)tabling.co.kr/ 만 허용");
|
||||||
|
}
|
||||||
restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
|
restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
|
||||||
cache.flush();
|
cache.flush();
|
||||||
return Map.of("ok", true);
|
return Map.of("ok", true);
|
||||||
@@ -292,7 +309,7 @@ public class RestaurantController {
|
|||||||
@PostMapping("/bulk-catchtable")
|
@PostMapping("/bulk-catchtable")
|
||||||
public SseEmitter bulkCatchtable() {
|
public SseEmitter bulkCatchtable() {
|
||||||
AuthUtil.requireAdmin();
|
AuthUtil.requireAdmin();
|
||||||
SseEmitter emitter = new SseEmitter(600_000L);
|
SseEmitter emitter = new SseEmitter(10_800_000L); // 3h — 대량 백필 대응
|
||||||
|
|
||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
try {
|
try {
|
||||||
@@ -367,116 +384,49 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/videos")
|
@GetMapping("/{id}/videos")
|
||||||
public List<Map<String, Object>> videos(@PathVariable String id) {
|
public List<Map<String, Object>> videos(
|
||||||
String key = cache.makeKey("restaurant_videos", id);
|
@PathVariable String id,
|
||||||
|
@RequestParam(name = "include_weak", defaultValue = "false") boolean includeWeak) {
|
||||||
|
String key = cache.makeKey("restaurant_videos", id, includeWeak ? "all" : "strong");
|
||||||
String cached = cache.getRaw(key);
|
String cached = cache.getRaw(key);
|
||||||
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");
|
||||||
var result = restaurantService.findVideoLinks(id);
|
var result = restaurantService.findVideoLinks(id, includeWeak);
|
||||||
cache.set(key, result);
|
cache.set(key, result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── DuckDuckGo HTML search helpers ─────────────────────────────────
|
// ─── 예약 사이트 URL 검색 (#357 WebSearchService: Naver primary + DDG fallback) ───
|
||||||
|
|
||||||
private static final HttpClient httpClient = HttpClient.newBuilder()
|
private List<Map<String, Object>> searchTabling(String restaurantName) {
|
||||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
return webSearch.search(
|
||||||
.build();
|
|
||||||
|
|
||||||
private static final Pattern DDG_RESULT_PATTERN = Pattern.compile(
|
|
||||||
"<a[^>]+class=\"result__a\"[^>]+href=\"([^\"]+)\"[^>]*>(.*?)</a>",
|
|
||||||
Pattern.DOTALL
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DuckDuckGo HTML 검색을 통해 특정 사이트의 URL을 찾는다.
|
|
||||||
* html.duckduckgo.com은 서버사이드 렌더링이라 봇 판정 없이 HTTP 요청만으로 결과를 파싱할 수 있다.
|
|
||||||
*/
|
|
||||||
private List<Map<String, Object>> searchDuckDuckGo(String query, String... urlPatterns) throws Exception {
|
|
||||||
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
|
|
||||||
String searchUrl = "https://html.duckduckgo.com/html/?q=" + encoded;
|
|
||||||
log.info("[DDG] Searching: {}", query);
|
|
||||||
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
|
||||||
.uri(URI.create(searchUrl))
|
|
||||||
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
|
||||||
.header("Accept", "text/html,application/xhtml+xml")
|
|
||||||
.header("Accept-Language", "ko-KR,ko;q=0.9")
|
|
||||||
.GET()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
|
||||||
String html = response.body();
|
|
||||||
|
|
||||||
List<Map<String, Object>> results = new ArrayList<>();
|
|
||||||
Set<String> seen = new HashSet<>();
|
|
||||||
Matcher matcher = DDG_RESULT_PATTERN.matcher(html);
|
|
||||||
|
|
||||||
while (matcher.find() && results.size() < 5) {
|
|
||||||
String href = matcher.group(1);
|
|
||||||
String title = matcher.group(2).replaceAll("<[^>]+>", "").trim();
|
|
||||||
|
|
||||||
// DDG 링크에서 실제 URL 추출 (uddg 파라미터)
|
|
||||||
String actualUrl = extractDdgUrl(href);
|
|
||||||
if (actualUrl == null) continue;
|
|
||||||
|
|
||||||
boolean matches = false;
|
|
||||||
for (String pattern : urlPatterns) {
|
|
||||||
if (actualUrl.contains(pattern)) {
|
|
||||||
matches = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (matches && !seen.contains(actualUrl)) {
|
|
||||||
seen.add(actualUrl);
|
|
||||||
results.add(Map.of("title", title, "url", actualUrl));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("[DDG] Found {} results for '{}'", results.size(), query);
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** DDG 리다이렉트 URL에서 실제 URL 추출 */
|
|
||||||
private String extractDdgUrl(String ddgHref) {
|
|
||||||
try {
|
|
||||||
// //duckduckgo.com/l/?uddg=ENCODED_URL&rut=...
|
|
||||||
if (ddgHref.contains("uddg=")) {
|
|
||||||
String uddgParam = ddgHref.substring(ddgHref.indexOf("uddg=") + 5);
|
|
||||||
int ampIdx = uddgParam.indexOf('&');
|
|
||||||
if (ampIdx > 0) uddgParam = uddgParam.substring(0, ampIdx);
|
|
||||||
return URLDecoder.decode(uddgParam, StandardCharsets.UTF_8);
|
|
||||||
}
|
|
||||||
// 직접 URL인 경우
|
|
||||||
if (ddgHref.startsWith("http")) return ddgHref;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.debug("[DDG] Failed to extract URL from: {}", ddgHref);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<Map<String, Object>> searchTabling(String restaurantName) throws Exception {
|
|
||||||
return searchDuckDuckGo(
|
|
||||||
"site:tabling.co.kr " + restaurantName,
|
"site:tabling.co.kr " + restaurantName,
|
||||||
"tabling.co.kr/restaurant/", "tabling.co.kr/place/"
|
"tabling.co.kr/restaurant/", "tabling.co.kr/place/"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Map<String, Object>> searchCatchtable(String restaurantName) throws Exception {
|
private List<Map<String, Object>> searchCatchtable(String restaurantName) {
|
||||||
return searchDuckDuckGo(
|
// 실제 캐치테이블 URL은 /ct/shop/ 형식. 옛 /dining/ /shop/ 패턴은 매칭 실패.
|
||||||
|
return webSearch.search(
|
||||||
"site:app.catchtable.co.kr " + restaurantName,
|
"site:app.catchtable.co.kr " + restaurantName,
|
||||||
"catchtable.co.kr/dining/", "catchtable.co.kr/shop/"
|
"catchtable.co.kr/ct/shop/", "catchtable.co.kr/ct/dining/"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,25 +434,12 @@ public class RestaurantController {
|
|||||||
* 식당 이름과 검색 결과 제목의 유사도 검사.
|
* 식당 이름과 검색 결과 제목의 유사도 검사.
|
||||||
* 한쪽 이름이 다른쪽에 포함되거나, 공통 글자 비율이 40% 이상이면 유사하다고 판단.
|
* 한쪽 이름이 다른쪽에 포함되거나, 공통 글자 비율이 40% 이상이면 유사하다고 판단.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* #348 — 한국어 자모 분해 + Sørensen-Dice bigram 유사도(임계값 0.45).
|
||||||
|
* 짧은 한국어 이름에서 이전 Jaccard-like(set 비율) 방식보다 정확.
|
||||||
|
*/
|
||||||
private boolean isNameSimilar(String restaurantName, String resultTitle) {
|
private boolean isNameSimilar(String restaurantName, String resultTitle) {
|
||||||
String a = normalize(restaurantName);
|
return com.tasteby.util.HangulSimilarity.similarity(restaurantName, resultTitle) >= 0.45;
|
||||||
String b = normalize(resultTitle);
|
|
||||||
if (a.isEmpty() || b.isEmpty()) return false;
|
|
||||||
|
|
||||||
// 포함 관계 체크
|
|
||||||
if (a.contains(b) || b.contains(a)) return true;
|
|
||||||
|
|
||||||
// 공통 문자 비율 (Jaccard-like)
|
|
||||||
var setA = a.chars().boxed().collect(java.util.stream.Collectors.toSet());
|
|
||||||
var setB = b.chars().boxed().collect(java.util.stream.Collectors.toSet());
|
|
||||||
long common = setA.stream().filter(setB::contains).count();
|
|
||||||
double ratio = (double) common / Math.max(setA.size(), setB.size());
|
|
||||||
return ratio >= 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String normalize(String s) {
|
|
||||||
if (s == null) return "";
|
|
||||||
return s.replaceAll("[\\s·\\-_()()\\[\\]【】]", "").toLowerCase();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void emit(SseEmitter emitter, Map<String, Object> data) {
|
private void emit(SseEmitter emitter, Map<String, Object> data) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
package com.tasteby.controller;
|
package com.tasteby.controller;
|
||||||
|
|
||||||
import com.tasteby.domain.SiteVisitStats;
|
import com.tasteby.domain.SiteVisitStats;
|
||||||
|
import com.tasteby.service.RateLimitService;
|
||||||
import com.tasteby.service.StatsService;
|
import com.tasteby.service.StatsService;
|
||||||
|
import com.tasteby.util.BotDetector;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -10,20 +15,51 @@ import java.util.Map;
|
|||||||
@RequestMapping("/api/stats")
|
@RequestMapping("/api/stats")
|
||||||
public class StatsController {
|
public class StatsController {
|
||||||
|
|
||||||
private final StatsService statsService;
|
private static final Logger log = LoggerFactory.getLogger(StatsController.class);
|
||||||
|
|
||||||
public StatsController(StatsService statsService) {
|
private final StatsService statsService;
|
||||||
|
private final RateLimitService rateLimitService;
|
||||||
|
|
||||||
|
public StatsController(StatsService statsService, RateLimitService rateLimitService) {
|
||||||
this.statsService = statsService;
|
this.statsService = statsService;
|
||||||
|
this.rateLimitService = rateLimitService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/visit")
|
@PostMapping("/visit")
|
||||||
public Map<String, Object> recordVisit() {
|
public Map<String, Object> recordVisit(HttpServletRequest req) {
|
||||||
|
// #337 — 봇 UA + IP 레이트리밋. 모두 통과해야 카운트 진행.
|
||||||
|
String ua = req.getHeader("User-Agent");
|
||||||
|
if (BotDetector.isBot(ua)) {
|
||||||
|
log.debug("visit skipped (bot): {}", ua);
|
||||||
|
return Map.of("ok", true, "counted", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
String clientIp = resolveClientIp(req);
|
||||||
|
if (!rateLimitService.tryConsume(clientIp)) {
|
||||||
|
log.debug("visit skipped (rate-limit): {}", clientIp);
|
||||||
|
return Map.of("ok", true, "counted", false);
|
||||||
|
}
|
||||||
|
|
||||||
statsService.recordVisit();
|
statsService.recordVisit();
|
||||||
return Map.of("ok", true);
|
return Map.of("ok", true, "counted", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/visits")
|
@GetMapping("/visits")
|
||||||
public SiteVisitStats getVisits() {
|
public SiteVisitStats getVisits() {
|
||||||
return statsService.getVisits();
|
return statsService.getVisits();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #337 — X-Forwarded-For 우선 (Nginx Ingress 뒤). chain이면 첫 번째(원본).
|
||||||
|
* 없으면 RemoteAddr 폴백.
|
||||||
|
*/
|
||||||
|
private static String resolveClientIp(HttpServletRequest req) {
|
||||||
|
String fwd = req.getHeader("X-Forwarded-For");
|
||||||
|
if (fwd != null && !fwd.isBlank()) {
|
||||||
|
int comma = fwd.indexOf(',');
|
||||||
|
return (comma > 0 ? fwd.substring(0, comma) : fwd).trim();
|
||||||
|
}
|
||||||
|
String addr = req.getRemoteAddr();
|
||||||
|
return addr != null ? addr : "unknown";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.ThreadLocalRandom;
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
@@ -182,7 +183,8 @@ public class VideoSseController {
|
|||||||
for (int i = 0; i < total; i++) {
|
for (int i = 0; i < total; i++) {
|
||||||
var v = rows.get(i);
|
var v = rows.get(i);
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
long delay = (long) (3000 + Math.random() * 5000);
|
// #325 — ThreadLocalRandom으로 통일 (bulkTranscript와 일관성)
|
||||||
|
long delay = 3000L + ThreadLocalRandom.current().nextLong(5000);
|
||||||
emit(emitter, Map.of("type", "wait", "index", i, "delay", delay / 1000.0));
|
emit(emitter, Map.of("type", "wait", "index", i, "delay", delay / 1000.0));
|
||||||
Thread.sleep(delay);
|
Thread.sleep(delay);
|
||||||
}
|
}
|
||||||
@@ -347,13 +349,15 @@ public class VideoSseController {
|
|||||||
@PostMapping("/rebuild-vectors")
|
@PostMapping("/rebuild-vectors")
|
||||||
public SseEmitter rebuildVectors() {
|
public SseEmitter rebuildVectors() {
|
||||||
AuthUtil.requireAdmin();
|
AuthUtil.requireAdmin();
|
||||||
SseEmitter emitter = new SseEmitter(600_000L);
|
SseEmitter emitter = new SseEmitter(60_000L);
|
||||||
|
|
||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
try {
|
try {
|
||||||
emit(emitter, Map.of("type", "start"));
|
// #325 — 운영자에게 미구현 상태 명시 (이전: 즉시 complete(total=0) → 무반응 인상)
|
||||||
// TODO: Implement full vector rebuild using VectorService
|
emit(emitter, Map.of(
|
||||||
emit(emitter, Map.of("type", "complete", "total", 0));
|
"type", "not_implemented",
|
||||||
|
"message", "벡터 재생성은 아직 구현되지 않았습니다. 후속 이슈(#325/#331)에서 처리 예정입니다."
|
||||||
|
));
|
||||||
emitter.complete();
|
emitter.complete();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
emitter.completeWithError(e);
|
emitter.completeWithError(e);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.tasteby.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import jakarta.validation.constraints.DecimalMax;
|
||||||
|
import jakarta.validation.constraints.DecimalMin;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #358 식당 부분 업데이트 DTO.
|
||||||
|
* - null = 변경 없음 (toFieldMap에서 제외).
|
||||||
|
* - 화이트리스트는 record 필드로 표현 — Jackson SNAKE_CASE 매핑 유지.
|
||||||
|
* - URL: http(s) / "NONE" / 빈 문자열만 허용 ("NONE"은 DDG/Naver 매칭 실패 마킹).
|
||||||
|
*/
|
||||||
|
public record RestaurantUpdateDTO(
|
||||||
|
@Size(min = 1, max = 200)
|
||||||
|
String name,
|
||||||
|
|
||||||
|
@Size(max = 500)
|
||||||
|
String address,
|
||||||
|
|
||||||
|
@Size(max = 100)
|
||||||
|
String region,
|
||||||
|
|
||||||
|
@JsonProperty("cuisine_type")
|
||||||
|
@Size(max = 50)
|
||||||
|
String cuisineType,
|
||||||
|
|
||||||
|
@JsonProperty("price_range")
|
||||||
|
@Min(1) @Max(5)
|
||||||
|
Integer priceRange,
|
||||||
|
|
||||||
|
@Size(max = 50)
|
||||||
|
String phone,
|
||||||
|
|
||||||
|
@Pattern(regexp = "^(https?://.*|NONE|)$")
|
||||||
|
String website,
|
||||||
|
|
||||||
|
@JsonProperty("tabling_url")
|
||||||
|
@Pattern(regexp = "^(https?://.*|NONE|)$")
|
||||||
|
String tablingUrl,
|
||||||
|
|
||||||
|
@JsonProperty("catchtable_url")
|
||||||
|
@Pattern(regexp = "^(https?://.*|NONE|)$")
|
||||||
|
String catchtableUrl,
|
||||||
|
|
||||||
|
@DecimalMin("-90.0") @DecimalMax("90.0")
|
||||||
|
BigDecimal latitude,
|
||||||
|
|
||||||
|
@DecimalMin("-180.0") @DecimalMax("180.0")
|
||||||
|
BigDecimal longitude,
|
||||||
|
|
||||||
|
@JsonProperty("google_place_id")
|
||||||
|
@Size(max = 200)
|
||||||
|
String googlePlaceId,
|
||||||
|
|
||||||
|
@JsonProperty("business_status")
|
||||||
|
@Size(max = 50)
|
||||||
|
String businessStatus,
|
||||||
|
|
||||||
|
@DecimalMin("0.0") @DecimalMax("5.0")
|
||||||
|
BigDecimal rating,
|
||||||
|
|
||||||
|
@JsonProperty("rating_count")
|
||||||
|
@Min(0)
|
||||||
|
Integer ratingCount
|
||||||
|
) {
|
||||||
|
/** null이 아닌 필드만 DB 컬럼명 키로 변환. */
|
||||||
|
public Map<String, Object> toFieldMap() {
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
if (name != null) m.put("name", name);
|
||||||
|
if (address != null) m.put("address", address);
|
||||||
|
if (region != null) m.put("region", region);
|
||||||
|
if (cuisineType != null) m.put("cuisine_type", cuisineType);
|
||||||
|
if (priceRange != null) m.put("price_range", priceRange);
|
||||||
|
if (phone != null) m.put("phone", phone);
|
||||||
|
if (website != null) m.put("website", website);
|
||||||
|
if (tablingUrl != null) m.put("tabling_url", tablingUrl);
|
||||||
|
if (catchtableUrl != null) m.put("catchtable_url", catchtableUrl);
|
||||||
|
if (latitude != null) m.put("latitude", latitude);
|
||||||
|
if (longitude != null) m.put("longitude", longitude);
|
||||||
|
if (googlePlaceId != null) m.put("google_place_id", googlePlaceId);
|
||||||
|
if (businessStatus != null) m.put("business_status", businessStatus);
|
||||||
|
if (rating != null) m.put("rating", rating);
|
||||||
|
if (ratingCount != null) m.put("rating_count", ratingCount);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,11 +14,38 @@ 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();
|
||||||
|
|
||||||
|
// #356 영상-식당 관련도
|
||||||
|
void updateLinkRelevance(@Param("linkId") String linkId,
|
||||||
|
@Param("relevance") String relevance,
|
||||||
|
@Param("reason") String reason);
|
||||||
|
|
||||||
|
Map<String, Object> findLinkContext(@Param("linkId") String linkId);
|
||||||
|
|
||||||
|
List<Map<String, Object>> findUnevaluatedLinks(@Param("limit") int limit);
|
||||||
|
|
||||||
|
int countUnevaluatedLinks();
|
||||||
|
|
||||||
|
// #359 1단계 — google_place_id 중복 조회
|
||||||
|
List<Map<String, Object>> findDuplicatePlaceIdRows();
|
||||||
|
|
||||||
Restaurant findById(@Param("id") String id);
|
Restaurant findById(@Param("id") String id);
|
||||||
|
|
||||||
List<Map<String, Object>> findVideoLinks(@Param("restaurantId") String restaurantId);
|
List<Map<String, Object>> findVideoLinks(@Param("restaurantId") String restaurantId,
|
||||||
|
@Param("includeWeak") boolean includeWeak);
|
||||||
|
|
||||||
void insertRestaurant(Restaurant r);
|
void insertRestaurant(Restaurant r);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ public interface StatsMapper {
|
|||||||
|
|
||||||
void recordVisit();
|
void recordVisit();
|
||||||
|
|
||||||
int getTodayVisits();
|
long getTodayVisits();
|
||||||
|
|
||||||
int getTotalVisits();
|
long getTotalVisits();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,6 @@ public interface UserMapper {
|
|||||||
List<UserInfo> findAllWithCounts(@Param("limit") int limit, @Param("offset") int offset);
|
List<UserInfo> findAllWithCounts(@Param("limit") int limit, @Param("offset") int offset);
|
||||||
|
|
||||||
int countAll();
|
int countAll();
|
||||||
|
|
||||||
|
int updateAdmin(@Param("id") String id, @Param("admin") int admin);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,38 +5,52 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
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.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
|
import org.springframework.data.redis.core.Cursor;
|
||||||
|
import org.springframework.data.redis.core.ScanOptions;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Set;
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class CacheService {
|
public class CacheService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(CacheService.class);
|
private static final Logger log = LoggerFactory.getLogger(CacheService.class);
|
||||||
private static final String PREFIX = "tasteby:";
|
private static final String PREFIX = "tasteby:";
|
||||||
|
private static final String SCAN_PATTERN = PREFIX + "*";
|
||||||
|
private static final int SCAN_BATCH = 500;
|
||||||
|
|
||||||
private final StringRedisTemplate redis;
|
private final StringRedisTemplate redis;
|
||||||
private final ObjectMapper mapper;
|
private final ObjectMapper mapper;
|
||||||
private final Duration ttl;
|
private final Duration ttl;
|
||||||
private boolean disabled = false;
|
|
||||||
|
// #336 — disabled/errorCount/lastError는 헬스체크와 다른 호출 스레드 사이에서 안전하게 공유.
|
||||||
|
private volatile boolean disabled = false;
|
||||||
|
private final AtomicLong errorCount = new AtomicLong(0);
|
||||||
|
private volatile String lastError = null;
|
||||||
|
|
||||||
public CacheService(StringRedisTemplate redis, ObjectMapper mapper,
|
public CacheService(StringRedisTemplate redis, ObjectMapper mapper,
|
||||||
@Value("${app.cache.ttl-seconds:600}") int ttlSeconds) {
|
@Value("${app.cache.ttl-seconds:600}") int ttlSeconds) {
|
||||||
this.redis = redis;
|
this.redis = redis;
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
this.ttl = Duration.ofSeconds(ttlSeconds);
|
this.ttl = Duration.ofSeconds(ttlSeconds);
|
||||||
try {
|
this.disabled = !pingOk();
|
||||||
redis.getConnectionFactory().getConnection().ping();
|
if (!disabled) log.info("Redis connected");
|
||||||
log.info("Redis connected");
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Redis unavailable ({}), caching disabled", e.getMessage());
|
|
||||||
disabled = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String makeKey(String... parts) {
|
public String makeKey(String... parts) {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +62,7 @@ public class CacheService {
|
|||||||
return mapper.readValue(val, type);
|
return mapper.readValue(val, type);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("Cache get error: {}", e.getMessage());
|
recordError("get", e);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -58,7 +72,7 @@ public class CacheService {
|
|||||||
try {
|
try {
|
||||||
return redis.opsForValue().get(key);
|
return redis.opsForValue().get(key);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("Cache get error: {}", e.getMessage());
|
recordError("getRaw", e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,20 +83,114 @@ public class CacheService {
|
|||||||
String json = mapper.writeValueAsString(value);
|
String json = mapper.writeValueAsString(value);
|
||||||
redis.opsForValue().set(key, json, ttl);
|
redis.opsForValue().set(key, json, ttl);
|
||||||
} catch (JsonProcessingException e) {
|
} catch (JsonProcessingException e) {
|
||||||
log.debug("Cache set error: {}", e.getMessage());
|
recordError("set:serialize", e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
recordError("set", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #336 — KEYS 블로킹 명령 대체.
|
||||||
|
* SCAN으로 cursor 순회 후 UNLINK(논블로킹 삭제)로 일괄 삭제.
|
||||||
|
*/
|
||||||
public void flush() {
|
public void flush() {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
try {
|
Integer count = redis.execute((org.springframework.data.redis.core.RedisCallback<Integer>) conn -> {
|
||||||
Set<String> keys = redis.keys(PREFIX + "*");
|
List<byte[]> batch = new ArrayList<>(SCAN_BATCH);
|
||||||
if (keys != null && !keys.isEmpty()) {
|
int deleted = 0;
|
||||||
redis.delete(keys);
|
try (Cursor<byte[]> cursor = conn.keyCommands().scan(
|
||||||
|
ScanOptions.scanOptions().match(SCAN_PATTERN).count(SCAN_BATCH).build())) {
|
||||||
|
while (cursor.hasNext()) {
|
||||||
|
batch.add(cursor.next());
|
||||||
|
if (batch.size() >= SCAN_BATCH) {
|
||||||
|
deleted += unlinkBatch(conn, batch);
|
||||||
|
batch.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!batch.isEmpty()) {
|
||||||
|
deleted += unlinkBatch(conn, batch);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
recordError("flush:scan", e);
|
||||||
}
|
}
|
||||||
log.info("Cache flushed");
|
return deleted;
|
||||||
|
});
|
||||||
|
log.info("Cache flushed ({} keys via SCAN+UNLINK)", count == null ? 0 : count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int unlinkBatch(org.springframework.data.redis.connection.RedisConnection conn, List<byte[]> keys) {
|
||||||
|
try {
|
||||||
|
Long n = conn.keyCommands().unlink(keys.toArray(new byte[0][]));
|
||||||
|
return n == null ? 0 : n.intValue();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("Cache flush error: {}", e.getMessage());
|
// UNLINK 미지원 환경 대비 DEL 폴백
|
||||||
|
recordError("flush:unlink", e);
|
||||||
|
try {
|
||||||
|
Long n = conn.keyCommands().del(keys.toArray(new byte[0][]));
|
||||||
|
return n == null ? 0 : n.intValue();
|
||||||
|
} catch (Exception delErr) {
|
||||||
|
recordError("flush:del", delErr);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void del(String key) {
|
||||||
|
if (disabled) return;
|
||||||
|
try {
|
||||||
|
redis.delete(key);
|
||||||
|
} catch (Exception e) {
|
||||||
|
recordError("del", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #336 — Redis 다운 → disabled=true, 재기동되면 자동으로 disabled=false.
|
||||||
|
* 30초마다 ping 한 번(<1ms)이라 부하 미미.
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = 30_000L)
|
||||||
|
public void checkHealth() {
|
||||||
|
boolean ok = pingOk();
|
||||||
|
if (ok && disabled) {
|
||||||
|
disabled = false;
|
||||||
|
log.info("Redis recovered, caching re-enabled");
|
||||||
|
} else if (!ok && !disabled) {
|
||||||
|
disabled = true;
|
||||||
|
log.warn("Redis lost, caching disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean pingOk() {
|
||||||
|
RedisConnectionFactory factory = redis.getConnectionFactory();
|
||||||
|
if (factory == null) return false;
|
||||||
|
try (var conn = factory.getConnection()) {
|
||||||
|
conn.ping();
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
lastError = "ping: " + e.getMessage();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordError(String op, Exception e) {
|
||||||
|
long n = errorCount.incrementAndGet();
|
||||||
|
String msg = e.getMessage();
|
||||||
|
lastError = op + ": " + (msg == null ? e.getClass().getSimpleName() : msg);
|
||||||
|
// 한 번씩만 WARN, 나머지는 DEBUG로 (운영 로그 폭주 방지 — 단순한 throttle)
|
||||||
|
if (n == 1 || n % 100 == 0) {
|
||||||
|
log.warn("Cache {} error #{}: {}", op, n, lastError);
|
||||||
|
} else {
|
||||||
|
log.debug("Cache {} error #{}: {}", op, n, lastError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDisabled() {
|
||||||
|
return disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CacheStats getStats() {
|
||||||
|
return new CacheStats(disabled, errorCount.get(), lastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CacheStats(boolean disabled, long errorCount, String lastError) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.tasteby.service;
|
package com.tasteby.service;
|
||||||
|
|
||||||
import com.tasteby.domain.DaemonConfig;
|
import com.tasteby.domain.DaemonConfig;
|
||||||
|
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
|
||||||
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.beans.factory.annotation.Value;
|
||||||
@@ -37,6 +38,10 @@ public class DaemonScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Scheduled(fixedDelay = 30_000) // Check every 30 seconds
|
@Scheduled(fixedDelay = 30_000) // Check every 30 seconds
|
||||||
|
// #335 — 분산 락: 멀티 파드 환경에서 한 인스턴스만 실행. Redis 키 `lock:daemon-runner`.
|
||||||
|
// lockAtMostFor: 작업이 비정상 종료돼도 15분 후 강제 해제 (다음 cron이 잡을 수 있게)
|
||||||
|
// lockAtLeastFor: 빨리 끝나도 30초 동안 유지 (즉시 다른 cron이 같은 작업 잡는 것 방지)
|
||||||
|
@SchedulerLock(name = "daemon-runner", lockAtMostFor = "PT15M", lockAtLeastFor = "PT30S")
|
||||||
public void run() {
|
public void run() {
|
||||||
// 인스턴스 차원 차단(dev/prod 동일 DB 공유 환경에서 dev 쪽 동시 폴링 방지).
|
// 인스턴스 차원 차단(dev/prod 동일 DB 공유 환경에서 dev 쪽 동시 폴링 방지).
|
||||||
// dev .env: DAEMON_ENABLED=false → 이 인스턴스는 스케줄러 동작 안 함.
|
// dev .env: DAEMON_ENABLED=false → 이 인스턴스는 스케줄러 동작 안 함.
|
||||||
@@ -50,8 +55,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;
|
||||||
daemonConfigService.updateLastScan();
|
try {
|
||||||
|
newVideos = youTubeService.scanAllChannels();
|
||||||
|
} finally {
|
||||||
|
// #275 — 외부 호출 예외 시에도 last_scan_at을 갱신해 다음 cron까지의 backoff를 보장
|
||||||
|
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);
|
||||||
@@ -63,8 +73,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;
|
||||||
daemonConfigService.updateLastProcess();
|
try {
|
||||||
|
restaurants = pipelineService.processPending(config.getProcessLimit());
|
||||||
|
} finally {
|
||||||
|
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 {
|
||||||
mapper.insertMemo(IdGenerator.newId(), userId, restaurantId, rating, memoText, visitedStr);
|
try {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,26 +150,25 @@ public class OciGenAiService {
|
|||||||
return mapper.readValue(raw, Object.class);
|
return mapper.readValue(raw, Object.class);
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
|
|
||||||
// Try to recover truncated array
|
// #326 — Recover truncated array. Brace depth counter로 단일 패스 O(N).
|
||||||
|
// 이전: 각 idx에서 end를 1씩 늘려가며 매번 readValue → O(N²) + 예외 스택트레이스 양산.
|
||||||
if (raw.trim().startsWith("[")) {
|
if (raw.trim().startsWith("[")) {
|
||||||
List<Object> items = new ArrayList<>();
|
List<Object> items = new ArrayList<>();
|
||||||
int idx = raw.indexOf('[') + 1;
|
int idx = raw.indexOf('[') + 1;
|
||||||
while (idx < raw.length()) {
|
while (idx < raw.length()) {
|
||||||
while (idx < raw.length() && " \t\n\r,".indexOf(raw.charAt(idx)) >= 0) idx++;
|
while (idx < raw.length() && " \t\n\r,".indexOf(raw.charAt(idx)) >= 0) idx++;
|
||||||
if (idx >= raw.length() || raw.charAt(idx) == ']') break;
|
if (idx >= raw.length() || raw.charAt(idx) == ']') break;
|
||||||
|
if (raw.charAt(idx) != '{') break; // 객체 시작이 아니면 복구 중단
|
||||||
|
|
||||||
// Try to parse next object
|
int end = findObjectEnd(raw, idx);
|
||||||
boolean found = false;
|
if (end < 0) break; // 잘린 객체 — 거기서 멈춤
|
||||||
for (int end = idx + 1; end <= raw.length(); end++) {
|
try {
|
||||||
try {
|
Object obj = mapper.readValue(raw.substring(idx, end + 1), Object.class);
|
||||||
Object obj = mapper.readValue(raw.substring(idx, end), Object.class);
|
items.add(obj);
|
||||||
items.add(obj);
|
} catch (Exception ignored2) {
|
||||||
idx = end;
|
break; // 불가해 객체 — 멈춤
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
} catch (Exception ignored2) {}
|
|
||||||
}
|
}
|
||||||
if (!found) break;
|
idx = end + 1;
|
||||||
}
|
}
|
||||||
if (!items.isEmpty()) {
|
if (!items.isEmpty()) {
|
||||||
log.info("Recovered {} items from truncated JSON", items.size());
|
log.info("Recovered {} items from truncated JSON", items.size());
|
||||||
@@ -179,4 +178,27 @@ public class OciGenAiService {
|
|||||||
|
|
||||||
throw new RuntimeException("JSON parse failed: " + raw.substring(0, Math.min(80, raw.length())));
|
throw new RuntimeException("JSON parse failed: " + raw.substring(0, Math.min(80, raw.length())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #326 — JSON 객체 시작 위치(`{`)에서 매칭되는 닫는 `}` 인덱스를 반환.
|
||||||
|
* 문자열 안의 `{` `}`와 escape는 무시. 매칭 못 찾으면 -1.
|
||||||
|
*/
|
||||||
|
private static int findObjectEnd(String raw, int start) {
|
||||||
|
int depth = 0;
|
||||||
|
boolean inString = false;
|
||||||
|
boolean escaped = false;
|
||||||
|
for (int i = start; i < raw.length(); i++) {
|
||||||
|
char c = raw.charAt(i);
|
||||||
|
if (escaped) { escaped = false; continue; }
|
||||||
|
if (c == '\\') { escaped = true; continue; }
|
||||||
|
if (c == '"') { inString = !inString; continue; }
|
||||||
|
if (inString) continue;
|
||||||
|
if (c == '{') depth++;
|
||||||
|
else if (c == '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth == 0) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ 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;
|
||||||
|
private final VideoRelevanceService relevanceService;
|
||||||
|
|
||||||
public PipelineService(YouTubeService youTubeService,
|
public PipelineService(YouTubeService youTubeService,
|
||||||
ExtractorService extractorService,
|
ExtractorService extractorService,
|
||||||
@@ -35,7 +37,9 @@ public class PipelineService {
|
|||||||
RestaurantService restaurantService,
|
RestaurantService restaurantService,
|
||||||
VideoService videoService,
|
VideoService videoService,
|
||||||
VectorService vectorService,
|
VectorService vectorService,
|
||||||
CacheService cacheService) {
|
CacheService cacheService,
|
||||||
|
RestaurantVerifyService verifyService,
|
||||||
|
VideoRelevanceService relevanceService) {
|
||||||
this.youTubeService = youTubeService;
|
this.youTubeService = youTubeService;
|
||||||
this.extractorService = extractorService;
|
this.extractorService = extractorService;
|
||||||
this.geocodingService = geocodingService;
|
this.geocodingService = geocodingService;
|
||||||
@@ -43,6 +47,8 @@ public class PipelineService {
|
|||||||
this.videoService = videoService;
|
this.videoService = videoService;
|
||||||
this.vectorService = vectorService;
|
this.vectorService = vectorService;
|
||||||
this.cacheService = cacheService;
|
this.cacheService = cacheService;
|
||||||
|
this.verifyService = verifyService;
|
||||||
|
this.relevanceService = relevanceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,6 +90,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 +111,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);
|
||||||
|
|
||||||
@@ -131,13 +148,16 @@ public class PipelineService {
|
|||||||
evaluationJson = JsonUtil.toJson(s);
|
evaluationJson = JsonUtil.toJson(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
restaurantService.linkVideoRestaurant(
|
String linkId = restaurantService.linkVideoRestaurant(
|
||||||
videoDbId, restId,
|
videoDbId, restId,
|
||||||
foods instanceof List<?> ? (List<String>) foods : null,
|
foods instanceof List<?> ? (List<String>) foods : null,
|
||||||
evaluationJson,
|
evaluationJson,
|
||||||
guests instanceof List<?> ? (List<String>) guests : null
|
guests instanceof List<?> ? (List<String>) guests : null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// #356 — 신규 등록 직후 비동기 관련도 평가
|
||||||
|
relevanceService.verifyAsync(linkId);
|
||||||
|
|
||||||
// Vector embeddings
|
// Vector embeddings
|
||||||
var chunks = VectorService.buildChunks(name, restData, title);
|
var chunks = VectorService.buildChunks(name, restData, title);
|
||||||
if (!chunks.isEmpty()) {
|
if (!chunks.isEmpty()) {
|
||||||
@@ -150,6 +170,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());
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.tasteby.service;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #337 — IP 기반 레이트리밋 (방문 카운트 어뷰즈 차단).
|
||||||
|
*
|
||||||
|
* 단순 Redis SETIFABSENT(SET NX EX) 패턴:
|
||||||
|
* - 첫 호출 시 키 등록 + TTL → 허용
|
||||||
|
* - TTL 동안 다음 호출은 키 존재로 차단
|
||||||
|
*
|
||||||
|
* Redis 다운 시 fail-open (true 반환) — 사용자 페이지 로드 우선.
|
||||||
|
* 멀티 파드 + Redis 단일 인스턴스 환경에서 자연스럽게 동작.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class RateLimitService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(RateLimitService.class);
|
||||||
|
private static final String PREFIX = "ratelimit:visit:";
|
||||||
|
|
||||||
|
private final StringRedisTemplate redis;
|
||||||
|
|
||||||
|
@Value("${app.rate-limit.visit-window-seconds:60}")
|
||||||
|
private long visitWindowSeconds;
|
||||||
|
|
||||||
|
public RateLimitService(StringRedisTemplate redis) {
|
||||||
|
this.redis = redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 IP의 visit 호출 허용 여부.
|
||||||
|
* @return true = 허용 (첫 호출 또는 윈도우 만료), false = 차단 (윈도우 안 재호출)
|
||||||
|
*/
|
||||||
|
public boolean tryConsume(String ipKey) {
|
||||||
|
try {
|
||||||
|
String key = PREFIX + ipKey;
|
||||||
|
Boolean ok = redis.opsForValue().setIfAbsent(key, "1", Duration.ofSeconds(visitWindowSeconds));
|
||||||
|
return Boolean.TRUE.equals(ok);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// fail-open: Redis 문제로 통계가 약간 부풀어도 사용자 영향 X
|
||||||
|
log.debug("RateLimit error (fail-open): {}", e.getMessage());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
@@ -52,7 +77,11 @@ public class RestaurantService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<Map<String, Object>> findVideoLinks(String restaurantId) {
|
public List<Map<String, Object>> findVideoLinks(String restaurantId) {
|
||||||
var rows = mapper.findVideoLinks(restaurantId);
|
return findVideoLinks(restaurantId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> findVideoLinks(String restaurantId, boolean includeWeak) {
|
||||||
|
var rows = mapper.findVideoLinks(restaurantId, includeWeak);
|
||||||
return rows.stream().map(row -> {
|
return rows.stream().map(row -> {
|
||||||
var m = JsonUtil.lowerKeys(row);
|
var m = JsonUtil.lowerKeys(row);
|
||||||
m.put("foods_mentioned", JsonUtil.parseStringList(m.get("foods_mentioned")));
|
m.put("foods_mentioned", JsonUtil.parseStringList(m.get("foods_mentioned")));
|
||||||
@@ -62,6 +91,43 @@ public class RestaurantService {
|
|||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #356 영상-식당 관련도
|
||||||
|
public void updateLinkRelevance(String linkId, String relevance, String reason) {
|
||||||
|
mapper.updateLinkRelevance(linkId, relevance, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> findLinkContext(String linkId) {
|
||||||
|
var row = mapper.findLinkContext(linkId);
|
||||||
|
return row != null ? JsonUtil.lowerKeys(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> findUnevaluatedLinks(int limit) {
|
||||||
|
return mapper.findUnevaluatedLinks(limit).stream()
|
||||||
|
.map(JsonUtil::lowerKeys)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int countUnevaluatedLinks() {
|
||||||
|
return mapper.countUnevaluatedLinks();
|
||||||
|
}
|
||||||
|
|
||||||
|
// #359 1단계 — google_place_id 중복 그룹 (참조 카운트 동봉)
|
||||||
|
public List<Map<String, Object>> findDuplicatePlaceIdGroups() {
|
||||||
|
var rows = mapper.findDuplicatePlaceIdRows().stream()
|
||||||
|
.map(JsonUtil::lowerKeys)
|
||||||
|
.toList();
|
||||||
|
Map<String, List<Map<String, Object>>> grouped = new LinkedHashMap<>();
|
||||||
|
for (var r : rows) {
|
||||||
|
String key = (String) r.get("google_place_id");
|
||||||
|
grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(r);
|
||||||
|
}
|
||||||
|
List<Map<String, Object>> out = new ArrayList<>(grouped.size());
|
||||||
|
for (var e : grouped.entrySet()) {
|
||||||
|
out.add(Map.of("google_place_id", e.getKey(), "items", e.getValue()));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
public void update(String id, Map<String, Object> fields) {
|
public void update(String id, Map<String, Object> fields) {
|
||||||
mapper.updateFields(id, fields);
|
mapper.updateFields(id, fields);
|
||||||
}
|
}
|
||||||
@@ -113,11 +179,13 @@ public class RestaurantService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void linkVideoRestaurant(String videoId, String restaurantId, List<String> foods, String evaluation, List<String> guests) {
|
public String linkVideoRestaurant(String videoId, String restaurantId, List<String> foods, String evaluation, List<String> guests) {
|
||||||
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);
|
||||||
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@@ -37,11 +38,13 @@ public class ReviewService {
|
|||||||
return mapper.findById(id);
|
return mapper.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional // #334 — 단일 SQL이지만 어노테이션 일관성
|
||||||
public boolean update(String reviewId, String userId, Double rating, String reviewText, LocalDate visitedAt) {
|
public boolean update(String reviewId, String userId, Double rating, String reviewText, LocalDate visitedAt) {
|
||||||
String visitedStr = visitedAt != null ? visitedAt.toString() : null;
|
String visitedStr = visitedAt != null ? visitedAt.toString() : null;
|
||||||
return mapper.updateReview(reviewId, userId, rating, reviewText, visitedStr) > 0;
|
return mapper.updateReview(reviewId, userId, rating, reviewText, visitedStr) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional // #334 — 단일 SQL이지만 어노테이션 일관성
|
||||||
public boolean delete(String reviewId, String userId) {
|
public boolean delete(String reviewId, String userId) {
|
||||||
return mapper.deleteReview(reviewId, userId) > 0;
|
return mapper.deleteReview(reviewId, userId) > 0;
|
||||||
}
|
}
|
||||||
@@ -60,10 +63,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() {
|
||||||
mapper.recordVisit();
|
// #274 — 자정 경계 동시성: 두 트랜잭션이 동시에 'NOT MATCHED' 판정 → 둘 다 INSERT
|
||||||
|
// → PK/UNIQUE 충돌 시 한 쪽 500. 1회 재시도(다음엔 MATCHED → UPDATE 분기).
|
||||||
|
try {
|
||||||
|
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() {
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package com.tasteby.service;
|
|||||||
import com.tasteby.domain.UserInfo;
|
import com.tasteby.domain.UserInfo;
|
||||||
import com.tasteby.mapper.UserMapper;
|
import com.tasteby.mapper.UserMapper;
|
||||||
import com.tasteby.util.IdGenerator;
|
import com.tasteby.util.IdGenerator;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -47,4 +49,12 @@ public class UserService {
|
|||||||
public int countAll() {
|
public int countAll() {
|
||||||
return mapper.countAll();
|
return mapper.countAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void updateAdmin(String userId, boolean admin) {
|
||||||
|
int rows = mapper.updateAdmin(userId, admin ? 1 : 0);
|
||||||
|
if (rows == 0) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.tasteby.service;
|
package com.tasteby.service;
|
||||||
|
|
||||||
|
import com.tasteby.util.IdGenerator;
|
||||||
import com.tasteby.util.JsonUtil;
|
import com.tasteby.util.JsonUtil;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@@ -27,12 +29,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 = """
|
||||||
@@ -61,6 +66,9 @@ public class VectorService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Save vector embeddings for a restaurant.
|
* Save vector embeddings for a restaurant.
|
||||||
|
*
|
||||||
|
* #331 — N개 청크를 단일 batchUpdate 호출로 처리 (이전: N+1 INSERT round-trip).
|
||||||
|
* UUID 생성은 IdGenerator.newId() 공통 유틸 사용 (인라인 변환 코드 제거).
|
||||||
*/
|
*/
|
||||||
public void saveRestaurantVectors(String restaurantId, List<String> chunks) {
|
public void saveRestaurantVectors(String restaurantId, List<String> chunks) {
|
||||||
if (chunks.isEmpty()) return;
|
if (chunks.isEmpty()) return;
|
||||||
@@ -72,19 +80,20 @@ public class VectorService {
|
|||||||
VALUES (:id, :rid, :chunk, :emb)
|
VALUES (:id, :rid, :chunk, :emb)
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
SqlParameterSource[] batch = new SqlParameterSource[chunks.size()];
|
||||||
for (int i = 0; i < chunks.size(); i++) {
|
for (int i = 0; i < chunks.size(); i++) {
|
||||||
String id = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase();
|
List<Double> emb = embeddings.get(i);
|
||||||
float[] vec = new float[embeddings.get(i).size()];
|
float[] vec = new float[emb.size()];
|
||||||
for (int j = 0; j < vec.length; j++) {
|
for (int j = 0; j < vec.length; j++) {
|
||||||
vec[j] = embeddings.get(i).get(j).floatValue();
|
vec[j] = emb.get(j).floatValue();
|
||||||
}
|
}
|
||||||
var params = new MapSqlParameterSource();
|
batch[i] = new MapSqlParameterSource()
|
||||||
params.addValue("id", id);
|
.addValue("id", IdGenerator.newId())
|
||||||
params.addValue("rid", restaurantId);
|
.addValue("rid", restaurantId)
|
||||||
params.addValue("chunk", chunks.get(i));
|
.addValue("chunk", chunks.get(i))
|
||||||
params.addValue("emb", vec);
|
.addValue("emb", vec);
|
||||||
jdbc.update(sql, params);
|
|
||||||
}
|
}
|
||||||
|
jdbc.batchUpdate(sql, batch);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package com.tasteby.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
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.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #356 영상-식당 관련도 LLM 평가.
|
||||||
|
* 설계서: docs/design/356-video-relevance-llm/README.md
|
||||||
|
*
|
||||||
|
* 신규 등록 시 자동 평가 + 어드민 백필. 결과는 video_restaurants.relevance에 저장.
|
||||||
|
* - strong: 본격 다룸 (방문 리뷰, 메뉴 평가)
|
||||||
|
* - weak: 잠깐 언급, 비교 대상
|
||||||
|
* - incidental: 일반 토픽 중 단순 언급, 입점 전
|
||||||
|
* - unknown: 미평가 or LLM 실패 (안전 기본값으로 표시 유지)
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class VideoRelevanceService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(VideoRelevanceService.class);
|
||||||
|
private static final Set<String> VALID = Set.of("strong", "weak", "incidental", "unknown");
|
||||||
|
private static final long BACKFILL_SLEEP_MS = 200;
|
||||||
|
|
||||||
|
private final RestaurantService restaurantService;
|
||||||
|
private final OciGenAiService genAi;
|
||||||
|
private final ObjectMapper jsonMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
public VideoRelevanceService(RestaurantService restaurantService, OciGenAiService genAi) {
|
||||||
|
this.restaurantService = restaurantService;
|
||||||
|
this.genAi = genAi;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
|
public void verifyAsync(String linkId) {
|
||||||
|
try {
|
||||||
|
verify(linkId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("verifyAsync failed for link {}: {}", linkId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void verify(String linkId) {
|
||||||
|
Map<String, Object> ctx = restaurantService.findLinkContext(linkId);
|
||||||
|
if (ctx == null) return;
|
||||||
|
VerifyResult result;
|
||||||
|
try {
|
||||||
|
String prompt = buildPrompt(ctx);
|
||||||
|
String response = genAi.chat(prompt, 120);
|
||||||
|
result = parseRelevance(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("verify({}) LLM failed: {} — keeping unknown", linkId, e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restaurantService.updateLinkRelevance(linkId, result.relevance(), truncate(result.reason(), 120));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
|
public void verifyAllAsync(int batchSize) {
|
||||||
|
try {
|
||||||
|
int n = verifyAll(batchSize);
|
||||||
|
log.info("[VideoRelevance] backfill done: {} processed", n);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("verifyAllAsync failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int verifyAll(int batchSize) {
|
||||||
|
int total = 0;
|
||||||
|
List<Map<String, Object>> batch;
|
||||||
|
while (!(batch = restaurantService.findUnevaluatedLinks(batchSize)).isEmpty()) {
|
||||||
|
for (Map<String, Object> row : batch) {
|
||||||
|
String linkId = (String) row.get("link_id");
|
||||||
|
if (linkId == null) continue;
|
||||||
|
try {
|
||||||
|
verify(linkId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("verifyAll({}) failed: {}", linkId, 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 ----
|
||||||
|
|
||||||
|
String buildPrompt(Map<String, Object> ctx) {
|
||||||
|
String foods = safeStr(ctx.get("foods_mentioned"));
|
||||||
|
String evaluation = safeStr(ctx.get("evaluation"));
|
||||||
|
return "다음 YouTube 영상이 이 식당을 어떻게 다루는지 판정하라.\n\n" +
|
||||||
|
"식당명: " + safeStr(ctx.get("restaurant_name")) + "\n" +
|
||||||
|
"주소: " + safeStr(ctx.get("address")) + "\n" +
|
||||||
|
"음식 분류: " + safeStr(ctx.get("cuisine_type")) + "\n" +
|
||||||
|
"언급된 음식: " + (foods.isEmpty() ? "(없음)" : foods) + "\n\n" +
|
||||||
|
"영상 제목: " + safeStr(ctx.get("video_title")) + "\n" +
|
||||||
|
"영상 채널: " + safeStr(ctx.get("channel_name")) + "\n" +
|
||||||
|
"영상에 등장한 평가: " + (evaluation.isEmpty() ? "(없음)" : evaluation) + "\n\n" +
|
||||||
|
"응답 형식(JSON만, 다른 텍스트 없이):\n" +
|
||||||
|
"{\"relevance\": \"strong\"|\"weak\"|\"incidental\", \"reason\": \"20자 이내 한국어\"}\n\n" +
|
||||||
|
"가이드:\n" +
|
||||||
|
"- strong: 영상이 이 식당을 본격 다룸 (방문 리뷰, 메뉴 평가).\n" +
|
||||||
|
"- weak: 잠깐 언급, 다른 식당과 비교 대상으로 등장.\n" +
|
||||||
|
"- incidental: 일반 토픽 중 단순 언급, 식당 입점 전 영상.\n" +
|
||||||
|
"- 판단 모호 시 strong (보수적 — 사용자에게 표시 유지).";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Pattern JSON_BLOCK = Pattern.compile("\\{[^{}]*\\}", Pattern.DOTALL);
|
||||||
|
|
||||||
|
VerifyResult parseRelevance(String raw) {
|
||||||
|
if (raw == null) return VerifyResult.unknown();
|
||||||
|
String trimmed = raw.trim();
|
||||||
|
String json = (trimmed.startsWith("{") && trimmed.endsWith("}")) ? trimmed : null;
|
||||||
|
if (json == null) {
|
||||||
|
Matcher m = JSON_BLOCK.matcher(raw);
|
||||||
|
if (m.find()) json = m.group();
|
||||||
|
}
|
||||||
|
if (json == null) return VerifyResult.unknown();
|
||||||
|
try {
|
||||||
|
JsonNode node = jsonMapper.readTree(json);
|
||||||
|
String rel = node.path("relevance").asText("unknown").toLowerCase();
|
||||||
|
if (!VALID.contains(rel)) rel = "unknown";
|
||||||
|
String reason = node.path("reason").asText("");
|
||||||
|
return new VerifyResult(rel, reason);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return VerifyResult.unknown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safeStr(Object o) {
|
||||||
|
return o == null ? "" : o.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String truncate(String s, int max) {
|
||||||
|
return s == null ? null : (s.length() <= max ? s : s.substring(0, max));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record VerifyResult(String relevance, String reason) {
|
||||||
|
public static VerifyResult unknown() { return new VerifyResult("unknown", "parse_failed"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package com.tasteby.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #357 웹 검색 추상화.
|
||||||
|
* - Naver Search webkr.json 우선 (한국 식당 정확도 높음, 무료 일 25k).
|
||||||
|
* - 키 미설정 또는 5xx/timeout 시 DDG HTML 파싱으로 폴백.
|
||||||
|
* - 결과는 urlPatterns로 필터링 (기존 searchDuckDuckGo와 동일 인터페이스).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class WebSearchService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(WebSearchService.class);
|
||||||
|
private static final int MAX_RESULTS = 5;
|
||||||
|
|
||||||
|
private static final Duration REQ_TIMEOUT = Duration.ofSeconds(15);
|
||||||
|
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||||
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.connectTimeout(Duration.ofSeconds(5))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
private static final Pattern DDG_RESULT = Pattern.compile(
|
||||||
|
"<a[^>]+class=\"result__a\"[^>]+href=\"([^\"]+)\"[^>]*>(.*?)</a>",
|
||||||
|
Pattern.DOTALL);
|
||||||
|
|
||||||
|
private final ObjectMapper json = new ObjectMapper();
|
||||||
|
private final String naverClientId;
|
||||||
|
private final String naverClientSecret;
|
||||||
|
|
||||||
|
public WebSearchService(
|
||||||
|
@Value("${app.naver.client-id:}") String naverClientId,
|
||||||
|
@Value("${app.naver.client-secret:}") String naverClientSecret) {
|
||||||
|
this.naverClientId = naverClientId == null ? "" : naverClientId.trim();
|
||||||
|
this.naverClientSecret = naverClientSecret == null ? "" : naverClientSecret.trim();
|
||||||
|
log.info("WebSearchService init — Naver={}", naverClientId.isEmpty() ? "off" : "on");
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> search(String query, String... urlPatterns) {
|
||||||
|
if (!naverClientId.isEmpty() && !naverClientSecret.isEmpty()) {
|
||||||
|
try {
|
||||||
|
List<Map<String, Object>> n = searchNaver(query, urlPatterns);
|
||||||
|
if (!n.isEmpty()) return n;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[NaverSearch] failed, falling back to DDG: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return searchDdg(query, urlPatterns);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[DDG] failed: {}", e.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Naver ───
|
||||||
|
|
||||||
|
List<Map<String, Object>> searchNaver(String query, String... urlPatterns) throws Exception {
|
||||||
|
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
|
||||||
|
String url = "https://openapi.naver.com/v1/search/webkr.json?query=" + encoded + "&display=30";
|
||||||
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.timeout(REQ_TIMEOUT)
|
||||||
|
.header("X-Naver-Client-Id", naverClientId)
|
||||||
|
.header("X-Naver-Client-Secret", naverClientSecret)
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (resp.statusCode() >= 400) {
|
||||||
|
throw new RuntimeException("Naver " + resp.statusCode());
|
||||||
|
}
|
||||||
|
JsonNode root = json.readTree(resp.body());
|
||||||
|
JsonNode items = root.path("items");
|
||||||
|
List<Map<String, Object>> out = new ArrayList<>();
|
||||||
|
Set<String> seen = new HashSet<>();
|
||||||
|
for (JsonNode it : items) {
|
||||||
|
if (out.size() >= MAX_RESULTS) break;
|
||||||
|
String link = it.path("link").asText("");
|
||||||
|
String title = stripTags(it.path("title").asText(""));
|
||||||
|
if (link.isEmpty() || !matchesPattern(link, urlPatterns)) continue;
|
||||||
|
if (seen.add(link)) out.add(Map.of("title", title, "url", link));
|
||||||
|
}
|
||||||
|
log.info("[NaverSearch] '{}' → {}", query, out.size());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DDG ───
|
||||||
|
|
||||||
|
List<Map<String, Object>> searchDdg(String query, String... urlPatterns) throws Exception {
|
||||||
|
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
|
||||||
|
String url = "https://html.duckduckgo.com/html/?q=" + encoded;
|
||||||
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.timeout(REQ_TIMEOUT)
|
||||||
|
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
||||||
|
.header("Accept", "text/html,application/xhtml+xml")
|
||||||
|
.header("Accept-Language", "ko-KR,ko;q=0.9")
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
|
||||||
|
String html = resp.body();
|
||||||
|
Matcher m = DDG_RESULT.matcher(html);
|
||||||
|
List<Map<String, Object>> out = new ArrayList<>();
|
||||||
|
Set<String> seen = new HashSet<>();
|
||||||
|
while (m.find() && out.size() < MAX_RESULTS) {
|
||||||
|
String href = m.group(1);
|
||||||
|
String title = m.group(2).replaceAll("<[^>]+>", "").trim();
|
||||||
|
String actual = extractDdgUrl(href);
|
||||||
|
if (actual == null || !matchesPattern(actual, urlPatterns)) continue;
|
||||||
|
if (seen.add(actual)) out.add(Map.of("title", title, "url", actual));
|
||||||
|
}
|
||||||
|
log.info("[DDG] '{}' → {}", query, out.size());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractDdgUrl(String ddgHref) {
|
||||||
|
try {
|
||||||
|
if (ddgHref.contains("uddg=")) {
|
||||||
|
String p = ddgHref.substring(ddgHref.indexOf("uddg=") + 5);
|
||||||
|
int amp = p.indexOf('&');
|
||||||
|
if (amp > 0) p = p.substring(0, amp);
|
||||||
|
return URLDecoder.decode(p, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
if (ddgHref.startsWith("http")) return ddgHref;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[DDG] url extract failed: {}", ddgHref);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String stripTags(String s) {
|
||||||
|
return s == null ? "" : s.replaceAll("<[^>]+>", "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean matchesPattern(String url, String[] patterns) {
|
||||||
|
if (patterns == null || patterns.length == 0) return true;
|
||||||
|
for (String p : patterns) {
|
||||||
|
if (url.contains(p)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -278,8 +278,19 @@ public class YouTubeService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch transcript for a YouTube video.
|
* Fetch transcript for a YouTube video.
|
||||||
* Tries API first (fast), then falls back to Playwright browser extraction.
|
*
|
||||||
* @param mode "auto" = manual first then generated, "manual" = manual only, "generated" = generated only
|
* 흐름: (1) Playwright headed 브라우저 추출 → (2) 실패 시 youtube-transcript-api 폴백.
|
||||||
|
*
|
||||||
|
* <p>#325 — mode 인자 명세:
|
||||||
|
* <ul>
|
||||||
|
* <li>"auto" (기본): manual → generated 순서로 시도</li>
|
||||||
|
* <li>"manual": manual(사람이 쓴 자막)만</li>
|
||||||
|
* <li>"generated": 자동 생성 자막만</li>
|
||||||
|
* </ul>
|
||||||
|
* 주의: mode 인자는 <b>youtube-transcript-api 폴백 경로에서만 사용</b>됩니다.
|
||||||
|
* 브라우저 추출은 YouTube가 노출하는 자막 트랙 전체를 그대로 수신하므로 mode 무관.
|
||||||
|
*
|
||||||
|
* @param mode 위 설명 참조. null이면 "auto"로 간주.
|
||||||
*/
|
*/
|
||||||
public TranscriptResult getTranscript(String videoId, String mode) {
|
public TranscriptResult getTranscript(String videoId, String mode) {
|
||||||
if (mode == null) mode = "auto";
|
if (mode == null) mode = "auto";
|
||||||
|
|||||||
25
backend-java/src/main/java/com/tasteby/util/BotDetector.java
Normal file
25
backend-java/src/main/java/com/tasteby/util/BotDetector.java
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package com.tasteby.util;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #337 — User-Agent 기반 봇 패턴 매칭.
|
||||||
|
*
|
||||||
|
* Googlebot / bingbot / facebookexternalhit / 일반 crawler/spider 등을 일괄 차단.
|
||||||
|
* 빈 UA는 봇으로 간주하지 않음(모바일 앱 등 정상 케이스 보호).
|
||||||
|
*/
|
||||||
|
public final class BotDetector {
|
||||||
|
|
||||||
|
private BotDetector() {}
|
||||||
|
|
||||||
|
// 일반적인 봇/크롤러 패턴. 케이스 무시.
|
||||||
|
private static final Pattern BOT_PATTERN = Pattern.compile(
|
||||||
|
"bot|crawler|spider|slurp|scrap|fetch|monitor|preview|lighthouse",
|
||||||
|
Pattern.CASE_INSENSITIVE
|
||||||
|
);
|
||||||
|
|
||||||
|
public static boolean isBot(String userAgent) {
|
||||||
|
if (userAgent == null || userAgent.isBlank()) return false;
|
||||||
|
return BOT_PATTERN.matcher(userAgent).find();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.tasteby.util;
|
||||||
|
|
||||||
|
import java.text.Normalizer;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #348 — 한국어 자모 분해(Unicode NFD) + Sørensen-Dice bigram 유사도.
|
||||||
|
*
|
||||||
|
* 음절 단위 Jaccard보다 짧은 한국어 이름에 정확. 예:
|
||||||
|
* similarity("스타벅스 강남", "스타벅스 강남점") ≈ 0.85+
|
||||||
|
* similarity("스타벅스 강남", "스타벅스 종로") ≈ 0.55~0.85
|
||||||
|
* similarity("스타벅스", "맥도날드") < 0.20
|
||||||
|
*
|
||||||
|
* 공백/구두점은 제거하고 소문자화한 뒤 NFD 분해.
|
||||||
|
*/
|
||||||
|
public final class HangulSimilarity {
|
||||||
|
|
||||||
|
private HangulSimilarity() {}
|
||||||
|
|
||||||
|
/** 공백/구두점 제거 + 소문자화 + NFD 분해(한글 음절 → 자모). */
|
||||||
|
public static String decompose(String s) {
|
||||||
|
if (s == null || s.isEmpty()) return "";
|
||||||
|
String stripped = s.replaceAll("[\\s·\\-_()()\\[\\]【】]", "").toLowerCase();
|
||||||
|
return Normalizer.normalize(stripped, Normalizer.Form.NFD);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sørensen-Dice 계수 (bigram multiset 기반). 0.0~1.0.
|
||||||
|
* 동일 문자열 → 1.0. 빈 입력 → 0.0.
|
||||||
|
*/
|
||||||
|
public static double similarity(String a, String b) {
|
||||||
|
String da = decompose(a);
|
||||||
|
String db = decompose(b);
|
||||||
|
if (da.isEmpty() || db.isEmpty()) return 0.0;
|
||||||
|
if (da.equals(db)) return 1.0;
|
||||||
|
|
||||||
|
// 포함 관계는 강한 신호로 1.0 처리 (기존 동작과 일관)
|
||||||
|
if (da.contains(db) || db.contains(da)) return 1.0;
|
||||||
|
|
||||||
|
if (da.length() < 2 || db.length() < 2) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Integer> bigramsA = bigrams(da);
|
||||||
|
Map<String, Integer> bigramsB = bigrams(db);
|
||||||
|
int common = 0;
|
||||||
|
for (var e : bigramsA.entrySet()) {
|
||||||
|
Integer countB = bigramsB.get(e.getKey());
|
||||||
|
if (countB != null) {
|
||||||
|
common += Math.min(e.getValue(), countB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int sizeA = da.length() - 1;
|
||||||
|
int sizeB = db.length() - 1;
|
||||||
|
return (2.0 * common) / (sizeA + sizeB);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Integer> bigrams(String s) {
|
||||||
|
Map<String, Integer> map = new HashMap<>();
|
||||||
|
for (int i = 0; i < s.length() - 1; i++) {
|
||||||
|
String gram = s.substring(i, i + 2);
|
||||||
|
map.merge(gram, 1, Integer::sum);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,8 @@ public final class JsonUtil {
|
|||||||
try {
|
try {
|
||||||
return MAPPER.readValue(json, new TypeReference<>() {});
|
return MAPPER.readValue(json, new TypeReference<>() {});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return Collections.emptyMap();
|
// Plain text or malformed JSON (e.g. Python-style single quotes) → wrap as {"text": "..."}
|
||||||
|
return Map.of("text", json.trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +75,24 @@ public final class JsonUtil {
|
|||||||
return rows.stream().map(JsonUtil::lowerKeys).collect(Collectors.toList());
|
return rows.stream().map(JsonUtil::lowerKeys).collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize evaluation to a valid JSON object string (e.g. {"text":"..."}).
|
||||||
|
* Plain text is wrapped, already-valid JSON is returned as-is, and text is truncated to maxLen.
|
||||||
|
*/
|
||||||
|
public static String normalizeEvaluation(String eval, int maxLen) {
|
||||||
|
if (eval == null || eval.isBlank()) return null;
|
||||||
|
String trimmed = eval.trim();
|
||||||
|
if (trimmed.startsWith("{")) return trimmed;
|
||||||
|
if (trimmed.length() > maxLen) {
|
||||||
|
trimmed = trimmed.substring(0, maxLen);
|
||||||
|
}
|
||||||
|
return toJson(Map.of("text", trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String normalizeEvaluation(String eval) {
|
||||||
|
return normalizeEvaluation(eval, 300);
|
||||||
|
}
|
||||||
|
|
||||||
public static String toJson(Object value) {
|
public static String toJson(Object value) {
|
||||||
try {
|
try {
|
||||||
return MAPPER.writeValueAsString(value);
|
return MAPPER.writeValueAsString(value);
|
||||||
|
|||||||
@@ -56,9 +56,28 @@ app:
|
|||||||
youtube-api-key: ${YOUTUBE_DATA_API_KEY}
|
youtube-api-key: ${YOUTUBE_DATA_API_KEY}
|
||||||
client-id: ${GOOGLE_CLIENT_ID:635551099330-2l003d3ernjmkqavd4f6s78r8r405iml.apps.googleusercontent.com}
|
client-id: ${GOOGLE_CLIENT_ID:635551099330-2l003d3ernjmkqavd4f6s78r8r405iml.apps.googleusercontent.com}
|
||||||
|
|
||||||
|
# #357 — Naver Search API (Tabling/Catchtable URL 매칭). 미설정 시 DDG 폴백.
|
||||||
|
naver:
|
||||||
|
client-id: ${NAVER_CLIENT_ID:}
|
||||||
|
client-secret: ${NAVER_CLIENT_SECRET:}
|
||||||
|
|
||||||
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}
|
||||||
|
|
||||||
|
rate-limit:
|
||||||
|
# #337 — 같은 IP에서 visit 카운트 허용 간격(초). 기본 60.
|
||||||
|
visit-window-seconds: ${VISIT_WINDOW_SECONDS:60}
|
||||||
|
|
||||||
|
build:
|
||||||
|
# #338 — 배포 시 deploy.sh가 env로 주입. dev에서는 dev/unknown.
|
||||||
|
version: ${APP_VERSION:dev}
|
||||||
|
commit: ${APP_COMMIT:unknown}
|
||||||
|
|
||||||
daemon:
|
daemon:
|
||||||
# 인스턴스 차원 스케줄러 활성화. dev/prod가 같은 DB를 공유하므로
|
# 인스턴스 차원 스케줄러 활성화. dev/prod가 같은 DB를 공유하므로
|
||||||
# dev .env에 DAEMON_ENABLED=false를 설정해 dev 폴링을 끄고 prod만 동작시킨다.
|
# dev .env에 DAEMON_ENABLED=false를 설정해 dev 폴링을 끄고 prod만 동작시킨다.
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- #356 영상-식당 관련도 LLM 평가
|
||||||
|
ALTER TABLE video_restaurants ADD (
|
||||||
|
relevance VARCHAR2(16) DEFAULT 'unknown' NOT NULL,
|
||||||
|
relevance_reason VARCHAR2(120),
|
||||||
|
relevance_evaluated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_vr_relevance ON video_restaurants(relevance);
|
||||||
@@ -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>
|
||||||
@@ -62,14 +69,20 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findVideoLinks" resultType="map">
|
<select id="findVideoLinks" resultType="map">
|
||||||
SELECT v.video_id, v.title, v.url,
|
<!-- #356 — relevance 컬럼 SELECT + includeWeak 가드 -->
|
||||||
|
SELECT vr.id AS link_id,
|
||||||
|
v.video_id, v.title, v.url,
|
||||||
TO_CHAR(v.published_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS published_at,
|
TO_CHAR(v.published_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS published_at,
|
||||||
vr.foods_mentioned, vr.evaluation, vr.guests,
|
vr.foods_mentioned, vr.evaluation, vr.guests,
|
||||||
|
vr.relevance, vr.relevance_reason,
|
||||||
c.channel_name, c.channel_id
|
c.channel_name, c.channel_id
|
||||||
FROM video_restaurants vr
|
FROM video_restaurants vr
|
||||||
JOIN videos v ON v.id = vr.video_id
|
JOIN videos v ON v.id = vr.video_id
|
||||||
JOIN channels c ON c.id = v.channel_id
|
JOIN channels c ON c.id = v.channel_id
|
||||||
WHERE vr.restaurant_id = #{restaurantId}
|
WHERE vr.restaurant_id = #{restaurantId}
|
||||||
|
<if test="includeWeak == null or !includeWeak">
|
||||||
|
AND vr.relevance IN ('strong', 'unknown')
|
||||||
|
</if>
|
||||||
ORDER BY v.published_at DESC
|
ORDER BY v.published_at DESC
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@@ -277,4 +290,84 @@
|
|||||||
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>
|
||||||
|
|
||||||
|
<!-- ===== #356 영상-식당 관련도 ===== -->
|
||||||
|
|
||||||
|
<update id="updateLinkRelevance">
|
||||||
|
UPDATE video_restaurants
|
||||||
|
SET relevance = #{relevance},
|
||||||
|
relevance_reason = #{reason,jdbcType=VARCHAR},
|
||||||
|
relevance_evaluated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = #{linkId}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="findLinkContext" resultType="map">
|
||||||
|
<!-- LLM 평가에 필요한 정보 -->
|
||||||
|
SELECT vr.id AS link_id, vr.foods_mentioned, vr.evaluation,
|
||||||
|
r.id AS restaurant_id, r.name AS restaurant_name, r.address, r.cuisine_type,
|
||||||
|
v.title AS video_title, c.channel_name
|
||||||
|
FROM video_restaurants vr
|
||||||
|
JOIN restaurants r ON r.id = vr.restaurant_id
|
||||||
|
JOIN videos v ON v.id = vr.video_id
|
||||||
|
JOIN channels c ON c.id = v.channel_id
|
||||||
|
WHERE vr.id = #{linkId}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findUnevaluatedLinks" resultType="map">
|
||||||
|
SELECT id AS link_id FROM video_restaurants
|
||||||
|
WHERE relevance_evaluated_at IS NULL
|
||||||
|
FETCH FIRST #{limit} ROWS ONLY
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countUnevaluatedLinks" resultType="int">
|
||||||
|
SELECT COUNT(*) FROM video_restaurants WHERE relevance_evaluated_at IS NULL
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- #359 1단계 — google_place_id 중복 조회 (그룹 식당 + 참조 카운트) -->
|
||||||
|
<select id="findDuplicatePlaceIdRows" resultType="map">
|
||||||
|
SELECT r.id, r.google_place_id, r.name, r.address,
|
||||||
|
TO_CHAR(r.created_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS created_at,
|
||||||
|
r.hidden,
|
||||||
|
(SELECT COUNT(*) FROM video_restaurants vr WHERE vr.restaurant_id = r.id) AS video_count,
|
||||||
|
(SELECT COUNT(*) FROM user_reviews rv WHERE rv.restaurant_id = r.id) AS review_count,
|
||||||
|
(SELECT COUNT(*) FROM user_memos mm WHERE mm.restaurant_id = r.id) AS memo_count
|
||||||
|
FROM restaurants r
|
||||||
|
WHERE r.google_place_id IN (
|
||||||
|
SELECT google_place_id FROM restaurants
|
||||||
|
WHERE google_place_id IS NOT NULL
|
||||||
|
GROUP BY google_place_id HAVING COUNT(*) > 1
|
||||||
|
)
|
||||||
|
ORDER BY r.google_place_id, r.created_at
|
||||||
|
</select>
|
||||||
|
|
||||||
</mapper>
|
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
WHEN NOT MATCHED THEN INSERT (visit_date, visit_count) VALUES (src.d, 1)
|
WHEN NOT MATCHED THEN INSERT (visit_date, visit_count) VALUES (src.d, 1)
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<select id="getTodayVisits" resultType="int">
|
<select id="getTodayVisits" resultType="long">
|
||||||
SELECT NVL(visit_count, 0)
|
SELECT NVL(visit_count, 0)
|
||||||
FROM site_visits
|
FROM site_visits
|
||||||
WHERE visit_date = TRUNC(SYSDATE)
|
WHERE visit_date = TRUNC(SYSDATE)
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="getTotalVisits" resultType="int">
|
<select id="getTotalVisits" resultType="long">
|
||||||
SELECT NVL(SUM(visit_count), 0)
|
SELECT NVL(SUM(visit_count), 0)
|
||||||
FROM site_visits
|
FROM site_visits
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findAllWithCounts" resultMap="userResultMap">
|
<select id="findAllWithCounts" resultMap="userResultMap">
|
||||||
SELECT u.id, u.email, u.nickname, u.avatar_url, u.provider, u.created_at,
|
SELECT u.id, u.email, u.nickname, u.avatar_url, u.is_admin, u.provider, u.created_at,
|
||||||
NVL(fav.cnt, 0) AS favorite_count,
|
NVL(fav.cnt, 0) AS favorite_count,
|
||||||
NVL(rev.cnt, 0) AS review_count,
|
NVL(rev.cnt, 0) AS review_count,
|
||||||
NVL(memo.cnt, 0) AS memo_count
|
NVL(memo.cnt, 0) AS memo_count
|
||||||
@@ -53,4 +53,8 @@
|
|||||||
SELECT COUNT(*) FROM tasteby_users
|
SELECT COUNT(*) FROM tasteby_users
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<update id="updateAdmin">
|
||||||
|
UPDATE tasteby_users SET is_admin = #{admin,jdbcType=NUMERIC} WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -62,15 +62,18 @@ if [[ "$TARGET" == "all" || "$TARGET" == "frontend" ]]; then
|
|||||||
# Read build args from env or .env file
|
# Read build args from env or .env file
|
||||||
MAPS_KEY="${NEXT_PUBLIC_GOOGLE_MAPS_API_KEY:-}"
|
MAPS_KEY="${NEXT_PUBLIC_GOOGLE_MAPS_API_KEY:-}"
|
||||||
CLIENT_ID="${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}"
|
CLIENT_ID="${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}"
|
||||||
|
NAVER_MAP_ID="${NEXT_PUBLIC_NAVER_MAP_CLIENT_ID:-}"
|
||||||
|
|
||||||
if [[ -f frontend/.env.local ]]; then
|
if [[ -f frontend/.env.local ]]; then
|
||||||
MAPS_KEY="${MAPS_KEY:-$(grep NEXT_PUBLIC_GOOGLE_MAPS_API_KEY frontend/.env.local 2>/dev/null | cut -d= -f2)}"
|
MAPS_KEY="${MAPS_KEY:-$(grep NEXT_PUBLIC_GOOGLE_MAPS_API_KEY frontend/.env.local 2>/dev/null | cut -d= -f2)}"
|
||||||
CLIENT_ID="${CLIENT_ID:-$(grep NEXT_PUBLIC_GOOGLE_CLIENT_ID frontend/.env.local 2>/dev/null | cut -d= -f2)}"
|
CLIENT_ID="${CLIENT_ID:-$(grep NEXT_PUBLIC_GOOGLE_CLIENT_ID frontend/.env.local 2>/dev/null | cut -d= -f2)}"
|
||||||
|
NAVER_MAP_ID="${NAVER_MAP_ID:-$(grep NEXT_PUBLIC_NAVER_MAP_CLIENT_ID frontend/.env.local 2>/dev/null | cut -d= -f2)}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker build --platform "$PLATFORM" \
|
docker build --platform "$PLATFORM" \
|
||||||
--build-arg NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="$MAPS_KEY" \
|
--build-arg NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="$MAPS_KEY" \
|
||||||
--build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID="$CLIENT_ID" \
|
--build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID="$CLIENT_ID" \
|
||||||
|
--build-arg NEXT_PUBLIC_NAVER_MAP_CLIENT_ID="$NAVER_MAP_ID" \
|
||||||
-t "$REGISTRY/frontend:$TAG" \
|
-t "$REGISTRY/frontend:$TAG" \
|
||||||
-t "$REGISTRY/frontend:latest" \
|
-t "$REGISTRY/frontend:latest" \
|
||||||
frontend/
|
frontend/
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<!-- 함수 설계서. 작성: [AI] Architect. -->
|
||||||
|
|
||||||
|
# 함수 설계서: BottomSheet snap 정책 (#319)
|
||||||
|
|
||||||
|
> **상태**: Approved <!-- Draft | Approved | Superseded -->
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #319 / 부모 #301 · 관련 컴포넌트: `frontend/src/components/BottomSheet.tsx`
|
||||||
|
|
||||||
|
## 1. 책임
|
||||||
|
|
||||||
|
모바일 BottomSheet의 **3-snap 점착(snap) 점**과 **닫힘 임계 속도**를 정의하고, 사용자의 드래그 제스처가 어느 snap에 안착할지 결정하는 알고리즘을 명세한다.
|
||||||
|
|
||||||
|
## 2. 상수 (매직 넘버)
|
||||||
|
|
||||||
|
| 상수 | 값 | 의미 |
|
||||||
|
|------|----|------|
|
||||||
|
| `SNAP_POINTS.PEEK` | `0.4` | 초기/맨 아래 snap. 화면 높이의 40%만 시트가 보임 — 지도와 시트를 함께 보는 균형. |
|
||||||
|
| `SNAP_POINTS.HALF` | `0.55` | 중간 snap. 시트 콘텐츠 핵심(이름·평점·리뷰 첫 줄)이 잘 보이는 위치. |
|
||||||
|
| `SNAP_POINTS.FULL` | `0.92` | 거의 풀 화면. 8% 여백은 상단 스와이프 핸들·상태바를 위해 남김. |
|
||||||
|
| `VELOCITY_THRESHOLD` | `0.5` (vh/s) | 빠른 아래 스와이프 감지 기준. 초당 화면 높이의 50% 이상이면 "닫기 의도"로 간주. |
|
||||||
|
| `CLOSE_BELOW_RATIO` | `0.6 × PEEK` ≈ 0.24 | snap 후보 중 PEEK의 60% 아래로 끌어내리면 강제 닫힘. |
|
||||||
|
|
||||||
|
## 3. 결정 알고리즘 (`snapTo(height, velocity)`)
|
||||||
|
|
||||||
|
```
|
||||||
|
입력: height(현재 높이 ratio 0~1), velocity(아래 방향 vh/s, 양수=아래)
|
||||||
|
|
||||||
|
1. velocity > VELOCITY_THRESHOLD && height < HALF → onClose()
|
||||||
|
2. height < CLOSE_BELOW_RATIO → onClose()
|
||||||
|
3. 그 외: [PEEK, HALF, FULL] 중 height와 최단 거리인 점에 setHeight()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 왜 이 값들인가 (근거)
|
||||||
|
|
||||||
|
- **0.4/0.55/0.92**: 모바일 UX 가이드(Material/iOS BottomSheet)의 30%/50%/95% 패턴을 한국 식당 카드 콘텐츠 길이(이름 18px + 평점 16px + 영상 썸네일 200px 기준)에 맞춰 조정.
|
||||||
|
- **0.5 vh/s**: 일반 손가락 플릭 속도가 0.7~1.2 vh/s, 의도적인 닫기 스와이프 임계점.
|
||||||
|
- **0.24 close-below**: PEEK(0.4)의 60% — 우발적 드래그(<0.05) 차단 + 의도적 닫기(<0.24) 수용.
|
||||||
|
|
||||||
|
## 5. 변경 시 주의
|
||||||
|
|
||||||
|
- 사용자의 근육 기억(스와이프 거리 감각) 교란 위험이 있어 SNAP_POINTS 변경은 ADR로 분리할 것.
|
||||||
|
- VELOCITY_THRESHOLD를 낮추면 의도치 않은 닫힘 ↑, 높이면 닫기 어려움 ↑.
|
||||||
|
|
||||||
|
## 6. 테스트 권고
|
||||||
|
|
||||||
|
- `snapTo(0.45, 0.1)` → HALF로 안착
|
||||||
|
- `snapTo(0.2, 0.7)` → onClose 호출
|
||||||
|
- `snapTo(0.85, 0)` → FULL로 안착
|
||||||
|
- `snapTo(0.1, 0)` → onClose 호출 (CLOSE_BELOW_RATIO)
|
||||||
|
- 단위 테스트는 `utils/bottomSheetSnap.ts`로 함수 추출 후 #343에서 진행.
|
||||||
|
|
||||||
|
## 7. 미해결 질문
|
||||||
|
|
||||||
|
- 가로 모드 / 큰 폰트 접근성 모드에서 PEEK가 너무 작아 보이는 케이스 — 향후 동적 조정 검토.
|
||||||
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 토글) — 별도 이슈.
|
||||||
81
docs/design/326-parsejson-optimization/README.md
Normal file
81
docs/design/326-parsejson-optimization/README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# 설계서: OciGenAiService.parseJson 단일 패스 최적화 (#326)
|
||||||
|
|
||||||
|
> **상태**: Approved
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #326 · 부모: #292 (추출 파이프라인 Reviewer 후속, 09-Done)
|
||||||
|
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/OciGenAiService.java`
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
|
LLM 응답이 잘린(truncated) 배열일 때 `parseJson`의 복구 로직이 O(N²): 각 객체 시작점에서 `end`를 1씩 늘려가며 매번 `mapper.readValue(substring)`을 try. 8192 토큰 응답(약 30KB)에서 매우 비효율 + 매 try마다 Jackson 예외 객체 생성(스택트레이스 양산). brace depth counter로 단일 패스 O(N)으로 교체.
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
- **포함**: `parseJson`의 truncated-array 복구 로직을 brace depth counter로 변경.
|
||||||
|
- **제외**: `parseJson`의 마크다운/콤마 정규식 전처리는 그대로. Jackson streaming API 도입은 추가 최적화이지만 본 범위 밖.
|
||||||
|
|
||||||
|
## 3. 인수조건
|
||||||
|
|
||||||
|
- [ ] 정상 JSON 배열 → 동일 결과 반환.
|
||||||
|
- [ ] 잘린 배열(끝 `}` 누락) → 가능한 만큼 객체 추출 + 로그.
|
||||||
|
- [ ] 문자열 안의 `{` `}` `"` (escape 포함) 잘못 카운트 안 됨.
|
||||||
|
- [ ] 8192 token 응답 처리 시간 < 10ms (이전: 수백 ms 가능).
|
||||||
|
- [ ] 회귀 없음 (기존 추출 파이프라인 시나리오 통과).
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
- LLM 응답은 마크다운 + JSON 혼합 가능.
|
||||||
|
- 응답 크기 최대 약 30KB (8192 token × 4 char/token).
|
||||||
|
- mapper는 Jackson ObjectMapper.
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
parseJson(raw)
|
||||||
|
├ strip markdown/trailing commas (기존)
|
||||||
|
├ try readValue(raw) → 성공 시 반환
|
||||||
|
└ truncated array 복구:
|
||||||
|
idx = '['의 다음
|
||||||
|
while idx < len:
|
||||||
|
skip whitespace, ','
|
||||||
|
if raw[idx] != '{': break // 객체 아님
|
||||||
|
depth=0, inString=false, escaped=false
|
||||||
|
단일 패스로 객체 끝 (depth==0 && } 만남) 찾음
|
||||||
|
items.add(readValue(substring))
|
||||||
|
idx = 객체 끝 다음
|
||||||
|
return items
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 함수 명세
|
||||||
|
|
||||||
|
| 함수 | 책임 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| `parseJson(raw)` (수정) | brace depth + 단일 readValue | private 헬퍼 `findObjectEnd(raw, start)` 추출 |
|
||||||
|
|
||||||
|
## 7. 흐름
|
||||||
|
|
||||||
|
1. 기존 정규식 전처리.
|
||||||
|
2. 전체 파싱 시도.
|
||||||
|
3. 실패 + 배열 시작이면 위 알고리즘으로 객체 단위 복구.
|
||||||
|
|
||||||
|
## 8. 엣지케이스
|
||||||
|
|
||||||
|
- **빈 배열 `[]`**: 일반 readValue가 처리.
|
||||||
|
- **문자열 안 `{` `}`**: inString 토글로 무시.
|
||||||
|
- **escape `\"` `\\`**: escaped 토글로 무시.
|
||||||
|
- **객체가 아닌 원시값 배열 `[1, 2, 3]`**: 첫 char가 `{`가 아니므로 break. 전체 파싱이 성공할 경우 도달 안 함.
|
||||||
|
- **매우 짧은 응답**: 전체 파싱이 성공 → 복구 경로 미진입.
|
||||||
|
|
||||||
|
## 9. 테스트
|
||||||
|
|
||||||
|
- 정상 배열, 잘린 끝, 마크다운 wrap, escape 포함 5케이스 unit test (후속).
|
||||||
|
|
||||||
|
## 10. 리스크 & 대안
|
||||||
|
|
||||||
|
- **선택**: brace depth counter (단일 패스).
|
||||||
|
- **대안 A**: Jackson `JsonParser` streaming API — 더 빠르지만 코드 복잡.
|
||||||
|
- **대안 B**: 응답을 모두 받지 않고 streaming 파싱 — 본 범위 밖.
|
||||||
|
|
||||||
|
## 11. 미해결 질문
|
||||||
|
|
||||||
|
- LLM 응답에 객체 외 다른 타입 섞일 수 있는가? 현재 추출 결과는 `[{...}, {...}]` 형태로 가정.
|
||||||
107
docs/design/329-admin-split/README.md
Normal file
107
docs/design/329-admin-split/README.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# 설계서: admin/page.tsx 분리 + 토큰/SSE 유틸 통일 (#329)
|
||||||
|
|
||||||
|
> **상태**: Approved
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #329 · 부모: #304 (현행화 frontend-admin, 09-Done)
|
||||||
|
> · 구현 파일: `frontend/src/app/admin/page.tsx`, `frontend/src/app/admin/_panels/*.tsx` (신규), `frontend/src/lib/admin-utils.ts`
|
||||||
|
> · 테스트: 수동 (각 탭 진입 + 기존 시나리오 회귀)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
|
`admin/page.tsx`가 2817 LOC 단일 파일. 5개 패널이 같은 파일 안에 함께 있어 (a) 빌드 변경 시 무관한 패널까지 재빌드/재배포, (b) 코드 리뷰 시 충돌 가능성↑, (c) 로직 격리 어려움. 또한 `localStorage` 직접 호출 + SSE 파싱 코드 중복이 남아있어 #304 후속에서 한 번 더 정리.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
|
||||||
|
- **포함**
|
||||||
|
- 5개 패널을 `app/admin/_panels/<Panel>.tsx`로 추출 (Next.js underscore prefix로 라우팅 제외).
|
||||||
|
- `page.tsx`는 탭 라우팅 + 헤더 + 패널 import만.
|
||||||
|
- 남은 `localStorage.getItem("tasteby_token")` 호출 → `getAdminToken()`/`authHeaders()` 교체.
|
||||||
|
- SSE 파싱 중복(약 5~6곳) → `consumeSseStream()` 활용.
|
||||||
|
- 공유 타입(`AdminUser`/`UserFavorite`/`UserReview`/`UserMemo`/`VideoSortKey`)을 _panels 파일 내부 또는 `api.ts`에 옮김(짧은 타입만).
|
||||||
|
- **제외**
|
||||||
|
- 패널 내부 로직 변경 (state/effect 그대로).
|
||||||
|
- catch{/*ignore*/} 일괄 로깅 (별도 후속).
|
||||||
|
- ad-hoc 타입 캐스팅 정리 (별도 후속).
|
||||||
|
- 디자인 시스템 색상 통일.
|
||||||
|
|
||||||
|
## 3. 인수조건
|
||||||
|
|
||||||
|
- [ ] 5개 파일 신규: `_panels/{Channels,Videos,Restaurants,Users,Daemon}Panel.tsx`.
|
||||||
|
- [ ] `page.tsx` < 200 LOC (이전 2817).
|
||||||
|
- [ ] localStorage 직접 호출이 admin 페이지 내에 0건.
|
||||||
|
- [ ] SSE reader 직접 호출(`response.body?.getReader()`)이 0건.
|
||||||
|
- [ ] 모든 탭 진입 + 기존 시나리오 회귀 없음(빌드 통과 + 수동 smoke).
|
||||||
|
- [ ] dev 빌드 + 운영 배포 성공.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
- Next.js 16 (App Router). `_` prefix 디렉토리는 route 제외.
|
||||||
|
- `"use client"` 지시문 각 패널 파일 상단에 필요.
|
||||||
|
- React Server Components 미사용 (모두 클라이언트 컴포넌트).
|
||||||
|
- `useAuth()` 훅이 `auth-context`에 있음. 부모 `AdminPage`가 isAdmin 판정 후 prop으로 패널에 전달.
|
||||||
|
- 패널 간 상태 공유 없음 (각각 독립).
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
app/admin/
|
||||||
|
page.tsx ← 탭 라우팅 + 헤더 + CacheFlushButton + 패널 import
|
||||||
|
_panels/
|
||||||
|
ChannelsPanel.tsx ← 214 LOC
|
||||||
|
VideosPanel.tsx ← 1272 LOC (가장 큼)
|
||||||
|
RestaurantsPanel.tsx ← 667 LOC
|
||||||
|
UsersPanel.tsx ← 332 LOC
|
||||||
|
DaemonPanel.tsx ← 223 LOC
|
||||||
|
|
||||||
|
lib/admin-utils.ts (이미 존재)
|
||||||
|
├─ getAdminToken()
|
||||||
|
├─ authHeaders()
|
||||||
|
└─ consumeSseStream()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 함수 명세
|
||||||
|
|
||||||
|
| 단위 | 책임 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| `page.tsx` (재작성) | 탭 라우팅 + 패널 import | < 200 LOC |
|
||||||
|
| `_panels/*.tsx` (신규 5개) | 각 패널 로직 그대로 옮김 | "use client" |
|
||||||
|
| `localStorage` 호출 (~10곳) | `getAdminToken()`/`authHeaders()`로 통일 | 의미 동일 |
|
||||||
|
| SSE `getReader` (~5곳) | `consumeSseStream(resp, onEvent)`로 통일 | 의미 동일 |
|
||||||
|
|
||||||
|
## 7. 흐름
|
||||||
|
|
||||||
|
1. `_panels/` 디렉토리 생성.
|
||||||
|
2. 각 패널 함수 + 그에 종속된 타입/상수를 `<Panel>Panel.tsx`로 잘라 옮김.
|
||||||
|
3. 각 파일에 `"use client"` + import 추가.
|
||||||
|
4. `localStorage.getItem("tasteby_token")` → `getAdminToken()` 일괄.
|
||||||
|
5. SSE `getReader/decoder/buf.split/match` 패턴 → `consumeSseStream(resp, onEvent)` 일괄.
|
||||||
|
6. `page.tsx` 재작성 — 탭 라우팅 + 패널 import.
|
||||||
|
7. `npm run build`.
|
||||||
|
8. `pm2 restart` 또는 `deploy.sh --frontend-only`.
|
||||||
|
|
||||||
|
## 8. 엣지케이스
|
||||||
|
|
||||||
|
- **순환 import**: 패널 간 의존 없음 → 안전.
|
||||||
|
- **Type 중복**: `AdminUser` 등 패널 내부 타입은 그대로 옮김. 공유 타입은 `api.ts`에 있음.
|
||||||
|
- **default export vs named**: 각 패널은 named export. `page.tsx`에서 `import { ChannelsPanel } from "./_panels/ChannelsPanel"`.
|
||||||
|
- **빌드 크기**: 동일(코드 splitting은 별도 작업).
|
||||||
|
|
||||||
|
## 9. 테스트
|
||||||
|
|
||||||
|
- 빌드: `npm run build` 통과.
|
||||||
|
- 수동:
|
||||||
|
- `/admin` 접근 → 5탭 모두 진입 가능.
|
||||||
|
- 채널 추가, 영상 강제 추출(SSE), 식당 검색/수정, 유저 권한 토글, 데몬 설정 변경 — 모두 정상.
|
||||||
|
- 자동: 별도 후속(테스트 인프라 #343).
|
||||||
|
|
||||||
|
## 10. 리스크 & 대안
|
||||||
|
|
||||||
|
- **선택**: 5개 파일 추출 + 내부 로직 그대로.
|
||||||
|
- **대안 A**: 추출 + 내부 리팩터링 동시 — 회귀 위험↑, 별도 후속이 안전.
|
||||||
|
- **대안 B**: Atomic Design (atoms/molecules/organisms) — 큰 재구조화. 미루기.
|
||||||
|
- **트레이드오프**: 외형은 동일, 유지보수성/충돌 가능성만 개선. 점진적 접근.
|
||||||
|
|
||||||
|
## 11. 미해결 질문
|
||||||
|
|
||||||
|
- 공통 panel layout(헤더/리스트/페이징) 추상화 — 후속.
|
||||||
|
- VideosPanel 1272 LOC 내부 분할(상태 머신 + SSE 흐름 분리) — 별도 후속.
|
||||||
81
docs/design/331-vector-batch-insert/README.md
Normal file
81
docs/design/331-vector-batch-insert/README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# 설계서: VectorService batch insert + IdGenerator 공통화 (#331)
|
||||||
|
|
||||||
|
> **상태**: Approved
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #331 · 부모: #293 (검색/벡터 Reviewer 후속, 09-Done)
|
||||||
|
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/VectorService.java`
|
||||||
|
> · 테스트: 본 이슈 범위 밖 (단위 테스트 인프라 도입은 #343 후속 묶음에 해당)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
|
`VectorService.saveRestaurantVectors`가 chunk N개를 N번의 단건 `jdbc.update`로 처리한다. 현재 `buildChunks`가 1개 청크만 반환해 N=1이지만, 향후 chunk 분할 도입 시 N+1 INSERT 비효율. 또한 UUID 생성 코드가 인라인 변환(`UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase()`)으로 다른 곳의 `IdGenerator.newId()`와 중복.
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
- **포함**
|
||||||
|
- `jdbc.batchUpdate(sql, SqlParameterSource[])`로 단일 호출 전환.
|
||||||
|
- UUID 생성을 `IdGenerator.newId()` 공통 유틸로 교체.
|
||||||
|
- **제외**
|
||||||
|
- 단위/통합 테스트 도입 (테스트 인프라 미도입 — 별도 후속 #343 묶음).
|
||||||
|
- `buildChunks`의 chunk 분할 로직 자체 변경 (현재 단일 청크 정책 유지).
|
||||||
|
- `restaurant_vectors` 스키마 변경.
|
||||||
|
|
||||||
|
## 3. 인수조건
|
||||||
|
|
||||||
|
- [ ] `saveRestaurantVectors`가 한 번의 `jdbc.batchUpdate` 호출로 N개 청크 삽입.
|
||||||
|
- [ ] UUID 인라인 변환 제거 → `IdGenerator.newId()` 호출.
|
||||||
|
- [ ] 회귀 없음 — 신규 식당 등록 시 `restaurant_vectors`에 정상 row 추가.
|
||||||
|
- [ ] N=0 가드(`chunks.isEmpty()`)는 유지.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
- Spring `NamedParameterJdbcTemplate.batchUpdate(String, SqlParameterSource[])` 사용.
|
||||||
|
- Oracle VECTOR 타입 파라미터는 `float[]`로 그대로 바인딩 가능 (`MapSqlParameterSource.addValue`).
|
||||||
|
- 한 batch 안 `int[]` 반환 → batch 결과 카운트는 사용하지 않음(throw if 어쩌고 미적용).
|
||||||
|
- `IdGenerator.newId()` 시그니처: `public static String newId()` → 32-char uppercase hex (현재 인라인과 동일).
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
saveRestaurantVectors(restaurantId, chunks)
|
||||||
|
├ if chunks.isEmpty() → return
|
||||||
|
├ embeddings = genAi.embedTexts(chunks)
|
||||||
|
├ params[] = build N개 MapSqlParameterSource
|
||||||
|
│ .addValue("id", IdGenerator.newId())
|
||||||
|
│ .addValue("rid", restaurantId)
|
||||||
|
│ .addValue("chunk", chunks.get(i))
|
||||||
|
│ .addValue("emb", float[] embeddings[i])
|
||||||
|
└ jdbc.batchUpdate(sql, params)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 함수 명세
|
||||||
|
|
||||||
|
| 함수 | 책임 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| `VectorService.saveRestaurantVectors(id, chunks)` (수정) | batchUpdate 1회 | IdGenerator 사용 |
|
||||||
|
|
||||||
|
## 7. 흐름
|
||||||
|
|
||||||
|
1. embed 호출 (기존).
|
||||||
|
2. `SqlParameterSource[]` 생성.
|
||||||
|
3. `jdbc.batchUpdate(sql, params)` 단일 호출.
|
||||||
|
|
||||||
|
## 8. 엣지케이스
|
||||||
|
|
||||||
|
- **chunks 빈 배열**: 조기 return (기존 유지).
|
||||||
|
- **embed 결과와 chunks 크기 불일치**: 현재 OCI GenAI는 입력 N → 출력 N 보장. 안전 가드 추가는 본 범위 밖 (필요 시 후속).
|
||||||
|
|
||||||
|
## 9. 테스트 (수동만)
|
||||||
|
|
||||||
|
- dev에서 신규 식당 등록(데몬 또는 수동 trigger) → `SELECT count(*) FROM restaurant_vectors WHERE restaurant_id = '...'` 정상 row 확인.
|
||||||
|
|
||||||
|
## 10. 리스크 & 대안
|
||||||
|
|
||||||
|
- **선택**: `NamedParameterJdbcTemplate.batchUpdate`. 단일 트랜잭션 + 단일 round-trip.
|
||||||
|
- **대안 A**: `JdbcTemplate.batchUpdate(BatchPreparedStatementSetter)` — 더 저수준이지만 named param 손실.
|
||||||
|
- **대안 B**: MERGE로 upsert — 동일 restaurant_id 재처리 시 중복 제거 가능. 다만 본 이슈 범위 밖.
|
||||||
|
|
||||||
|
## 11. 미해결 질문
|
||||||
|
|
||||||
|
- chunk 분할 정책(현재 1개 단일 청크) — 후속 (검색 정확도 vs 토큰 비용 트레이드오프 결정).
|
||||||
|
- batchUpdate 결과 row 수 검증 — 운영 모니터링 도구 도입 후 결정.
|
||||||
94
docs/design/332-restaurant-update-whitelist/README.md
Normal file
94
docs/design/332-restaurant-update-whitelist/README.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# 설계서: Restaurant 임의 필드 업데이트 화이트리스트 (#332)
|
||||||
|
|
||||||
|
> **상태**: Approved
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #332 · 부모: #290 (식당 CRUD Reviewer 후속, 09-Done)
|
||||||
|
> · 구현 파일: `backend-java/src/main/java/com/tasteby/controller/RestaurantController.java`, `backend-java/src/main/java/com/tasteby/service/RestaurantService.java`
|
||||||
|
> · 테스트: 수동 (curl로 화이트리스트 외 필드 시도)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
|
`PUT /api/restaurants/{id}` body가 `Map<String, Object>`로 임의 키를 받는다. SQL 측 `updateFields`는 컬럼별 `<if test="containsKey('...')">` 가드로 화이트리스트가 이미 적용되어 있어 임의 컬럼 갱신은 차단된다. 다만 Controller 레벨에서 화이트리스트가 명시되지 않아 — (a) 의도 모호, (b) 향후 SQL 측 가드가 무력화되거나 다른 매퍼로 확장되면 위험. 명시적 화이트리스트로 의도 강화.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
|
||||||
|
- **포함**
|
||||||
|
- `RestaurantController.update(id, body)`에서 허용된 키만 통과시키는 `ALLOWED_UPDATE_FIELDS` set.
|
||||||
|
- 허용되지 않은 키는 무시(silently drop) + 디버그 로그. 400 응답은 hard policy라 사용자 경험 영향 큼.
|
||||||
|
- **제외 (out of scope)**
|
||||||
|
- DTO 클래스 도입 (RestaurantUpdateDTO + Bean Validation) — 더 강한 표준화지만 코드 영향 큼. 후속.
|
||||||
|
- DDG HTML 검색 → 정식 API 전환 (별도 후속, 비용/계약 결정 필요).
|
||||||
|
- isNameSimilar 한국어 자모 알고리즘 (별도 후속).
|
||||||
|
- UNIQUE(google_place_id) 제약 강화 (DB 마이그레이션 + 데이터 정리 필요, 별도).
|
||||||
|
|
||||||
|
## 3. 인수조건
|
||||||
|
|
||||||
|
- [ ] `ALLOWED_UPDATE_FIELDS` 상수 정의 (SQL `updateFields` 가드와 일치).
|
||||||
|
- [ ] PUT 호출 시 body에서 허용 외 키는 자동 제거 + 디버그 로그.
|
||||||
|
- [ ] 허용 키만 있는 정상 호출 → 200 정상 동작 회귀 없음.
|
||||||
|
- [ ] 허용 외 키만 있는 호출 → 200 + 변경 없음 (또는 200 + 빈 업데이트).
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
- Spring MVC. 변경 최소화. DTO 도입 없이도 가드 가능.
|
||||||
|
- 운영 영향 없음 (어드민이 화면에서 호출하는 키는 모두 허용 set 안에).
|
||||||
|
- 가정: 화이트리스트 set은 SQL `updateFields` 의 `<if>` 키들과 1:1.
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/restaurants/{id} body
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RestaurantController.update
|
||||||
|
├ requireAdmin
|
||||||
|
├ allowed = ALLOWED_UPDATE_FIELDS
|
||||||
|
├ filtered = body where key ∈ allowed
|
||||||
|
├ if (filtered.isEmpty()) → 200 + no-op
|
||||||
|
└ restaurantService.update(id, filtered) → mapper.updateFields
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
|
||||||
|
`Set<String> ALLOWED_UPDATE_FIELDS = Set.of(name, address, region, cuisine_type, price_range, phone, website, tabling_url, catchtable_url, latitude, longitude, google_place_id, business_status, rating, rating_count)` — SQL `updateFields`의 키들과 일치.
|
||||||
|
|
||||||
|
## 7. 함수 명세
|
||||||
|
|
||||||
|
| 함수 | 책임 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| `RestaurantController.update(id, body)` (수정) | 화이트리스트 필터 후 위임 | filtered.isEmpty()면 no-op |
|
||||||
|
|
||||||
|
## 8. 흐름
|
||||||
|
|
||||||
|
1. body Map 수신.
|
||||||
|
2. `body.entrySet().stream().filter(e -> ALLOWED_UPDATE_FIELDS.contains(e.getKey())).collect(toMap)`.
|
||||||
|
3. 비어있으면 200 응답하고 끝.
|
||||||
|
4. 아니면 `restaurantService.update(id, filtered)` 호출.
|
||||||
|
|
||||||
|
## 9. 엣지케이스
|
||||||
|
|
||||||
|
- **빈 body**: 200 + no-op.
|
||||||
|
- **허용 외 키만**: 200 + no-op + 디버그 로그.
|
||||||
|
- **null 값을 포함한 허용 키**: SQL `updateFields`가 그대로 NULL 저장 — 의도된 동작 (좌표/주소 해제 등).
|
||||||
|
|
||||||
|
## 10. 테스트
|
||||||
|
|
||||||
|
- 수동:
|
||||||
|
```
|
||||||
|
curl -X PUT -H "Authorization: Bearer <admin>" -H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"테스트", "is_admin":true}' /api/restaurants/<id>
|
||||||
|
→ name만 갱신, is_admin은 무시
|
||||||
|
```
|
||||||
|
- 자동: 별도 후속(통합 테스트 인프라 도입 시).
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안
|
||||||
|
|
||||||
|
- **선택**: Controller 화이트리스트 set + silent drop.
|
||||||
|
- **대안 A**: DTO + Bean Validation — 표준화 깔끔하지만 변경 범위 큼.
|
||||||
|
- **대안 B**: 허용 외 키 발견 시 400 — 사용자 경험 부담, 클라이언트가 잘못된 키 보내면 즉시 실패. 본 변경은 보수적으로 silent drop.
|
||||||
|
|
||||||
|
## 12. 미해결 질문
|
||||||
|
|
||||||
|
- DDG → 정식 검색 API 전환 — 별도 후속 (비용/계약 결정).
|
||||||
|
- isNameSimilar 한국어 알고리즘 — 별도 후속.
|
||||||
|
- DTO 도입 표준화 — 별도 후속.
|
||||||
104
docs/design/335-daemon-distributed-lock/README.md
Normal file
104
docs/design/335-daemon-distributed-lock/README.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# 설계서: 데몬 스케줄러 분산 락 (#335)
|
||||||
|
|
||||||
|
> **상태**: Approved
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #335 · 부모: #275 (현행화 backend-daemon, 09-Done)
|
||||||
|
> · 구현 파일: `backend-java/build.gradle`, `backend-java/src/main/java/com/tasteby/TastebyApplication.java`, `backend-java/src/main/java/com/tasteby/config/ShedLockConfig.java` (신규), `backend-java/src/main/java/com/tasteby/service/DaemonScheduler.java`
|
||||||
|
> · 테스트: 수동 (롤링 업데이트 시 두 파드 공존 시뮬레이션)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
|
OKE 운영에서 backend Pod 1개로 동작하지만 RollingUpdate(maxSurge>0) 시 신·구 Pod이 잠시 공존. 또한 dev(PM2)와 운영이 같은 Oracle ATP를 공유 — 이미 `DAEMON_ENABLED` 플래그로 dev 폴링은 차단했지만, 운영 자체에서 두 Pod이 같은 30초 주기로 `scanAllChannels`를 호출하면 YouTube/OCI GenAI 중복 호출 + 동일 영상 두 번 처리 + 봇 감지 위험. ShedLock으로 한 인스턴스만 실행하도록 보장.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
|
||||||
|
- **포함**
|
||||||
|
- `DaemonScheduler.run()`을 분산 락으로 보호 (lockAtMostFor + lockAtLeastFor).
|
||||||
|
- Lock provider: Redis (이미 운영 중인 in-cluster Redis 재사용).
|
||||||
|
- 의존성: `net.javacrumbs.shedlock:shedlock-spring`, `shedlock-provider-redis-spring`.
|
||||||
|
- **제외 (out of scope)**
|
||||||
|
- 다른 @Scheduled 메서드(CacheService.checkHealth, 향후 추가될 cron). 필요 시 같은 패턴으로 확장.
|
||||||
|
- 락 획득 실패 시 알람 — Spring Actuator/Micrometer 도입 후 후속.
|
||||||
|
- DB 기반 lock provider (JDBC) — Redis가 충분.
|
||||||
|
|
||||||
|
## 3. 인수조건
|
||||||
|
|
||||||
|
- [ ] build.gradle에 shedlock-spring + shedlock-provider-redis-spring 추가.
|
||||||
|
- [ ] `@EnableSchedulerLock` 활성화.
|
||||||
|
- [ ] `DaemonScheduler.run`에 `@SchedulerLock(name="daemon-runner", ...)` 적용.
|
||||||
|
- [ ] 락 키는 `lock:daemon-runner` 형태로 Redis에 저장 (prefix 기본).
|
||||||
|
- [ ] 운영 배포 후 로그에 lock acquire/release 메시지 또는 정상 동작 확인.
|
||||||
|
- [ ] 회귀 없음 — 자동 cron 정상 동작.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
- Redis는 in-cluster 단일 인스턴스. ShedLock의 Redis provider는 단일 인스턴스에서 SET NX EX로 동작.
|
||||||
|
- Pod 1개 운영이라 평소엔 락 경합 없음 → ShedLock 부하 미미 (Redis 1회 SET NX EX, <1ms).
|
||||||
|
- `lockAtMostFor`: 락이 강제로 해제되기까지 시간. `scanAllChannels`는 channel 6 × 영상 fetch 시간 ≈ 최대 10분 예상. `PT15M`로 안전 마진.
|
||||||
|
- `lockAtLeastFor`: 작업이 빨리 끝나도 락 유지하는 최소 시간 (다음 cron이 즉시 잡지 못하게). 30초 cycle이라 PT30S로 충분.
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
[Pod A] [Pod B]
|
||||||
|
│ │
|
||||||
|
│ @Scheduled(fixedDelay=30s)
|
||||||
|
▼ ▼
|
||||||
|
DaemonScheduler.run DaemonScheduler.run
|
||||||
|
│ │
|
||||||
|
│ @SchedulerLock │ @SchedulerLock
|
||||||
|
▼ ▼
|
||||||
|
LockProvider (Redis)
|
||||||
|
├─ SET lock:daemon-runner EX 900 NX ✓ → Pod A 진행
|
||||||
|
└─ SET lock:daemon-runner EX 900 NX ✗ → Pod B 즉시 종료(no-op)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
scanAllChannels / processPending 실행 (A만)
|
||||||
|
│
|
||||||
|
▼ 종료 시 락 키 lockUntil 시각으로 갱신 (lockAtLeastFor 보장)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
|
||||||
|
Redis 키 1개:
|
||||||
|
- key: `lock:daemon-runner`
|
||||||
|
- value: lockedBy(host:pid) + lockedAt
|
||||||
|
- expiry: lockAtMostFor
|
||||||
|
|
||||||
|
## 7. 함수 명세
|
||||||
|
|
||||||
|
| 함수 | 책임 | 시그니처 | 비고 |
|
||||||
|
|------|------|----------|------|
|
||||||
|
| `DaemonScheduler.run()` (수정) | @SchedulerLock 추가 | 기존 | name="daemon-runner" |
|
||||||
|
| `ShedLockConfig.lockProvider(...)` (신규) | Bean 등록 | `LockProvider lockProvider(RedisConnectionFactory)` | Redis provider |
|
||||||
|
|
||||||
|
## 8. 흐름
|
||||||
|
|
||||||
|
1. 30초마다 fixedDelay로 run() 호출.
|
||||||
|
2. ShedLock AOP가 SET NX EX 시도.
|
||||||
|
3. 성공: 본문 실행. 실패: 즉시 반환(no-op).
|
||||||
|
4. 본문 종료 시 lockUntil 갱신.
|
||||||
|
|
||||||
|
## 9. 엣지케이스
|
||||||
|
|
||||||
|
- **lockAtMostFor 초과 작업**: 락 자동 해제 후 다른 Pod이 잡을 수 있음. scanAllChannels가 15분 넘기지 않게 channel별 timeout 적용 권고(설계서 #275 §11 참고).
|
||||||
|
- **Pod 죽음**: lockAtMostFor 만료 후 자동 해제.
|
||||||
|
- **Redis 다운**: SET 실패 → Spring AOP가 RuntimeException → 다음 30초에 재시도. 캐시 disabled와 별개.
|
||||||
|
- **clock skew**: ShedLock은 Redis 서버 시간 기준이라 클러스터 노드 간 시간 차이 무관.
|
||||||
|
|
||||||
|
## 10. 테스트 계획
|
||||||
|
|
||||||
|
- 수동: Pod 2개 동시 실행 (kubectl scale deploy backend --replicas=2) 후 로그에서 한 쪽만 `Running scheduled channel scan...` 찍히는지 확인.
|
||||||
|
- 자동: 후속 (ShedLock 자체는 lib 차원에서 테스트됨).
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안
|
||||||
|
|
||||||
|
- **선택**: ShedLock + Redis.
|
||||||
|
- **대안 A**: Redis SET NX EX 수동 구현 — 가능하나 ShedLock이 lockAtMostFor/lockAtLeastFor 자동 처리해서 더 안전.
|
||||||
|
- **대안 B**: DB(Oracle) 기반 ShedLock — 추가 테이블 필요 + DB 부하. Redis가 더 단순.
|
||||||
|
- **대안 C**: 단일 leader pod (k8s Lease object) — Spring Cloud Kubernetes 도입 부담 크다.
|
||||||
|
|
||||||
|
## 12. 미해결 질문
|
||||||
|
|
||||||
|
- ShedLock 의존성이 standard library가 아닌 4th-party에 가까움 — 검증된 라이브러리(8년+ 사용, 4k+ stars)지만 향후 Spring 마이크로 버전 호환성은 별도 모니터링.
|
||||||
|
- CacheService.checkHealth는 락 안 걸어도 됨(idempotent). 추가 cron 도입 시 same name 충돌 주의.
|
||||||
162
docs/design/336-cache-scan-recovery/README.md
Normal file
162
docs/design/336-cache-scan-recovery/README.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# 설계서: 캐시 SCAN/UNLINK + disabled 자동 복구 + 에러 메트릭 (#336)
|
||||||
|
|
||||||
|
> **상태**: Approved <!-- Draft | Approved | Superseded -->
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #336 · 부모: #276 (현행화 backend-cache, 09-Done)
|
||||||
|
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/CacheService.java`, `backend-java/src/main/java/com/tasteby/controller/AdminCacheController.java`
|
||||||
|
> · 테스트: 후속 (Testcontainers Redis 인프라는 별도)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
|
`CacheService.flush()`가 `redis.keys("tasteby:*")` 블로킹 명령을 사용해 키가 많아지면 Redis 인스턴스 전체가 정지(Redis는 single-threaded). 또한 생성자에서 한 번 ping 실패하면 `disabled=true`로 영구 no-op 상태 — Redis가 재기동되어도 자동 복구 안 됨. 그리고 set/get/flush 실패가 DEBUG 로그로만 묻혀 운영 monitoring 사각지대.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
|
||||||
|
- **포함**
|
||||||
|
- `flush()`/추후 `flushByPrefix()`를 `SCAN` + `UNLINK`(논블로킹 삭제)로 교체.
|
||||||
|
- 30초 주기 헬스체크로 `disabled` 플래그 자동 토글 (Redis 재기동 시 자동 복구).
|
||||||
|
- 캐시 에러 카운터(in-memory `AtomicLong`)와 마지막 에러 메시지를 노출하는 admin 엔드포인트.
|
||||||
|
- **제외 (out of scope)**
|
||||||
|
- Micrometer/Prometheus 메트릭 stack 도입(별도 이슈, Spring Boot Actuator + 별도 인프라).
|
||||||
|
- Testcontainers Redis 기반 단위 테스트(별도 후속, 인프라 도입 비용 큼).
|
||||||
|
- 캐시 key 네임스페이스 다중화.
|
||||||
|
|
||||||
|
## 3. 인수조건 (Acceptance Criteria)
|
||||||
|
|
||||||
|
- [ ] `flush()`가 `KEYS` 대신 SCAN 커서 기반으로 동작한다 (블로킹 없음).
|
||||||
|
- [ ] 삭제는 `UNLINK`(Redis 4.0+ 논블로킹) 사용. 미지원 환경에서는 `DEL`로 폴백.
|
||||||
|
- [ ] Redis가 다운된 상태에서 startup → `disabled=true`. 이후 Redis 재기동되면 60초 이내 `disabled=false`로 자동 복구되어 set/get 정상 동작한다.
|
||||||
|
- [ ] set/get/flush/del의 예외는 `cacheErrorCount` 카운터가 증가하고 `lastError`에 메시지를 기록한다.
|
||||||
|
- [ ] `GET /api/admin/cache/stats` 가 `{ disabled, errorCount, lastError }` 응답.
|
||||||
|
- [ ] 기존 캐시 동작(hit/miss/TTL) 회귀 없음.
|
||||||
|
- [ ] 운영 배포 후 외부 `/api/restaurants` 응답이 캐시 hit 경로에서 변함없이 동작.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
- Spring Data Redis 3.x + Lettuce 클라이언트.
|
||||||
|
- 운영 Redis: OKE in-cluster (단일 인스턴스, persistence X). UNLINK 지원.
|
||||||
|
- 키 prefix: `tasteby:`. 현재 키 개수는 수십~수백 (식당/검색/채널 캐시), 향후 수만으로 증가 가능성.
|
||||||
|
- 30초 헬스체크 추가 부하 미미(ping 한 번 = 0.5ms 이하).
|
||||||
|
- 가정: `StringRedisTemplate.execute(RedisCallback)`에서 native `ScanOptions` + `RedisServerCommands.scan()` 사용 가능.
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Spring Context ]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
CacheService
|
||||||
|
├─ disabled : volatile boolean (자동 토글)
|
||||||
|
├─ errorCount : AtomicLong
|
||||||
|
├─ lastError : volatile String
|
||||||
|
│
|
||||||
|
├─ get/set/del/flush
|
||||||
|
│ └─ try {} catch { errorCount.incrementAndGet(); lastError = ...; }
|
||||||
|
│
|
||||||
|
├─ flush() ⟶ redis.execute(connection -> {
|
||||||
|
│ ScanOptions opt = match("tasteby:*").count(500);
|
||||||
|
│ while (cursor.hasNext()) keys.add(cursor.next());
|
||||||
|
│ if (!keys.isEmpty()) connection.keyCommands().unlink(keys);
|
||||||
|
│ })
|
||||||
|
│
|
||||||
|
└─ @Scheduled(fixedDelay=30_000)
|
||||||
|
checkHealth()
|
||||||
|
├ try { ping } → disabled = false (회복)
|
||||||
|
└ catch → disabled = true (또는 유지)
|
||||||
|
|
||||||
|
AdminCacheController
|
||||||
|
└─ GET /api/admin/cache/stats → { disabled, errorCount, lastError }
|
||||||
|
```
|
||||||
|
|
||||||
|
I/O ↔ 순수 로직 경계: SCAN 루프는 Redis 통신이지만 결과 처리는 단순 `for` 루프. 헬스체크는 단일 ping. 에러 기록은 atomic.
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
|
||||||
|
| 필드 | 타입 | 의미 |
|
||||||
|
|------|------|------|
|
||||||
|
| `disabled` | `volatile boolean` | 캐시 일시 비활성 (Redis 다운 시 true) |
|
||||||
|
| `errorCount` | `AtomicLong` | 누적 에러 횟수 (set/get/flush/del 통합) |
|
||||||
|
| `lastError` | `volatile String` | 최근 에러 메시지 (운영 디버깅용) |
|
||||||
|
|
||||||
|
응답 DTO:
|
||||||
|
```json
|
||||||
|
{ "disabled": false, "errorCount": 0, "lastError": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 함수 명세
|
||||||
|
|
||||||
|
| 함수 | 책임(1줄) | 시그니처 | 입력 | 출력 | 에러 | 복잡? |
|
||||||
|
|------|-----------|----------|------|------|------|-------|
|
||||||
|
| `CacheService.flush()` (수정) | SCAN+UNLINK 기반 prefix 삭제 | `void flush()` | - | side-effect | recordError() | **복잡** |
|
||||||
|
| `CacheService.checkHealth()` (신규) | 30초마다 ping → disabled 토글 | `void checkHealth()` (@Scheduled) | - | side-effect | disabled=true 유지 | 단순 |
|
||||||
|
| `CacheService.recordError(op, e)` (신규) | 카운터 증가 + lastError 기록 | `void recordError(String, Exception)` | op, e | side-effect | - | 단순 |
|
||||||
|
| `CacheService.getStats()` (신규) | 외부 노출용 stats | `CacheStats getStats()` | - | DTO | - | 단순 |
|
||||||
|
| `AdminCacheController.stats()` (신규) | GET endpoint | `Map stats()` | - | DTO | requireAdmin | 단순 |
|
||||||
|
|
||||||
|
## 8. 흐름 / 알고리즘
|
||||||
|
|
||||||
|
### flush (SCAN + UNLINK)
|
||||||
|
```
|
||||||
|
batch = 500 (한 번에 받는 키 수)
|
||||||
|
keys = []
|
||||||
|
redis.execute(conn ->
|
||||||
|
try (Cursor<byte[]> cursor = conn.keyCommands().scan(ScanOptions.scanOptions().match("tasteby:*").count(batch).build())) {
|
||||||
|
while (cursor.hasNext()) keys.add(new String(cursor.next()));
|
||||||
|
}
|
||||||
|
if (!keys.isEmpty()) conn.keyCommands().unlink(keys.toArray(byte[][]));
|
||||||
|
return null;
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 헬스체크
|
||||||
|
```
|
||||||
|
@Scheduled(fixedDelay = 30_000)
|
||||||
|
void checkHealth() {
|
||||||
|
if (DAEMON_ENABLED env가 false면 dev에서 노이즈 피해 skip 가능 — 단, 캐시 헬스체크는 데몬 플래그와 무관하니 항상 실행)
|
||||||
|
try (conn = factory.getConnection()) {
|
||||||
|
conn.ping();
|
||||||
|
if (disabled) { log.info("Redis recovered"); disabled = false; }
|
||||||
|
} catch (Exception e) {
|
||||||
|
if (!disabled) { log.warn("Redis lost: {}", e); disabled = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 에러 기록
|
||||||
|
```
|
||||||
|
catch (Exception e) {
|
||||||
|
errorCount.incrementAndGet();
|
||||||
|
lastError = op + ": " + e.getMessage();
|
||||||
|
log.warn("Cache {} error (count={}): {}", op, errorCount.get(), e.getMessage());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 엣지케이스 & 에러 처리
|
||||||
|
|
||||||
|
- **SCAN 중 다른 스레드가 키 추가/삭제**: SCAN의 best-effort 보장상 일부 키 누락 가능. flush의 자연 무효화(TTL)와 함께 작동하면 영향 미미.
|
||||||
|
- **UNLINK 미지원 Redis(2.x)**: Spring Data Redis가 fallback하지 않으므로 `DEL`로 명시 폴백. 운영 Redis는 6.x라 미지원 가능성 거의 없음.
|
||||||
|
- **헬스체크와 set/get 동시 호출**: volatile + atomic 사용. race 가능하지만 영향 작음 (잠시 후 보정).
|
||||||
|
- **로그 폭주**: 같은 에러가 매번 발생하면 WARN 로그가 폭주 — 운영에서 모니터링 후 throttle 검토 (후속).
|
||||||
|
- **fixedDelay=30s 가 너무 잦은가**: ping은 0.5ms 미만이라 무해.
|
||||||
|
|
||||||
|
## 10. 테스트 계획
|
||||||
|
|
||||||
|
- 수동:
|
||||||
|
- dev에서 Redis 임시 중단(`pm2 stop redis` 등) → 60초 후 `/api/admin/cache/stats` 의 disabled=true 확인.
|
||||||
|
- Redis 재기동 → 60초 이내 disabled=false 자동 복구 확인.
|
||||||
|
- `/api/restaurants` 호출로 캐시 set/get 작동 확인.
|
||||||
|
- 자동: Testcontainers Redis 기반 단위 테스트는 별도 후속.
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안 검토
|
||||||
|
|
||||||
|
- **선택**: SCAN + UNLINK + @Scheduled 헬스체크.
|
||||||
|
- **대안 A**: TTL만 의존(flush 폐기) — 단순하지만 즉시 무효화 불가, 어드민 강제 무효화 시나리오 손상.
|
||||||
|
- **대안 B**: Redis 6.0+의 `FLUSHDB ASYNC` — 더 단순하지만 prefix 격리 안 됨(다른 앱이 같은 Redis 공유 시 위험). tasteby Redis는 전용이라 가능하지만 일반화 위해 SCAN/UNLINK 채택.
|
||||||
|
- **대안 C**: Lettuce native `RedisAsyncCommands.scan` 직접 사용 — 더 빠르지만 추상화 레벨 낮춤.
|
||||||
|
- **트레이드오프**: SCAN은 N개 키마다 cursor 왕복 발생 → flush 1회 latency 증가(키 1만 개 기준 ~50ms). 비동기 UNLINK로 삭제는 빠름.
|
||||||
|
|
||||||
|
## 12. 미해결 질문
|
||||||
|
|
||||||
|
- Micrometer 메트릭(JVM/캐시) 도입 시 errorCount를 prom으로 export — 별도 후속.
|
||||||
|
- Redis sentinel/cluster 도입 시 헬스체크 의미 재정의 — 현재 단일 인스턴스라 무관.
|
||||||
|
- `lastError` 노출이 운영자에게 충분한가, 또는 sliding window가 필요한가 — 운영 24h 관찰 후 결정.
|
||||||
114
docs/design/337-stats-bot-ratelimit/README.md
Normal file
114
docs/design/337-stats-bot-ratelimit/README.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# 설계서: 통계 방문 카운트 봇 필터 + 레이트리밋 (#337)
|
||||||
|
|
||||||
|
> **상태**: Approved
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #337 · 부모: #274 (현행화 backend-stats, 09-Done)
|
||||||
|
> · 구현 파일: `backend-java/build.gradle`, `backend-java/src/main/java/com/tasteby/controller/StatsController.java`, `backend-java/src/main/java/com/tasteby/util/BotDetector.java` (신규), `backend-java/src/main/java/com/tasteby/service/RateLimitService.java` (신규)
|
||||||
|
> · 테스트: 수동 (curl로 봇 UA + 같은 IP 다중 호출)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
|
`POST /api/stats/visit`이 인증 없이 누구나 호출 가능 + 봇/크롤러 필터 없음 → (a) 일반 봇이 사이트 인덱싱하면서 카운터 인플레이션, (b) 악의적 새로고침 어뷰즈로 통계 왜곡. 또한 페이지 로드마다 DB write QPS 증가 — 클라이언트 어뷰즈에 취약.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
|
||||||
|
- **포함**
|
||||||
|
- User-Agent 기반 봇 패턴 필터 (Googlebot, bingbot, crawler 등 일반 패턴).
|
||||||
|
- IP 기반 레이트리밋: 같은 IP에서 1분에 1회만 카운트 (Bucket4j + Redis).
|
||||||
|
- X-Forwarded-For 헤더 우선 (Nginx Ingress 뒤이므로).
|
||||||
|
- 봇/리밋 초과: 200 응답하되 카운터 미증가 (사용자 경험 영향 X).
|
||||||
|
- **제외 (out of scope)**
|
||||||
|
- WAF 수준 봇 차단 (Cloudflare 등).
|
||||||
|
- UU(고유 방문자) — 별도 후속 (쿠키/세션).
|
||||||
|
- Redis INCR 기반 비동기 통계 (DB write 분리) — 별도 후속.
|
||||||
|
|
||||||
|
## 3. 인수조건
|
||||||
|
|
||||||
|
- [ ] build.gradle에 bucket4j-redis 추가.
|
||||||
|
- [ ] User-Agent에 'bot'/'crawler'/'spider' 포함 시 카운트 skip + 디버그 로그.
|
||||||
|
- [ ] 같은 IP에서 60초 안에 두 번째 visit 호출 시 카운트 skip.
|
||||||
|
- [ ] 응답은 항상 `{ "ok": true, "counted": bool }` 형태.
|
||||||
|
- [ ] 봇/리밋 초과 호출도 200 응답 (사용자 페이지 로드 지장 X).
|
||||||
|
- [ ] 운영 배포 후 회귀 없음 — 통계 카운터가 정상 증가.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
- Bucket4j 8.x + Redis backend (Lettuce 호환).
|
||||||
|
- 메모리만 사용하는 경우(단일 파드) ConcurrentLinkedHashMap도 가능하나 ShedLock 사례처럼 멀티 파드 미래 대비 Redis.
|
||||||
|
- 운영 트래픽 작아 Bucket4j 부하 미미.
|
||||||
|
- 1분 1회 정책은 동일 IP에서 사용자가 페이지 새로고침해도 영향 작음. 너무 빡빡하면 다중 사용자 NAT(회사/카페) 영향 → 1분/IP는 균형.
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자 페이지 로드
|
||||||
|
│
|
||||||
|
▼ POST /api/stats/visit (X-Forwarded-For: client-ip)
|
||||||
|
StatsController.recordVisit(req)
|
||||||
|
│
|
||||||
|
├─ BotDetector.isBot(userAgent) → skip
|
||||||
|
│
|
||||||
|
├─ RateLimitService.tryConsume(clientIp) → false면 skip
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
StatsService.recordVisit() → DB MERGE
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
|
||||||
|
Redis 키:
|
||||||
|
- `bucket4j:visit:<ip>` — Bucket4j가 자동 관리 (token bucket state)
|
||||||
|
|
||||||
|
응답:
|
||||||
|
```json
|
||||||
|
{ "ok": true, "counted": true } // 정상 카운트
|
||||||
|
{ "ok": true, "counted": false } // 봇/리밋 초과
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 함수 명세
|
||||||
|
|
||||||
|
| 함수 | 책임 | 시그니처 | 비고 |
|
||||||
|
|------|------|----------|------|
|
||||||
|
| `BotDetector.isBot(ua)` | UA 문자열 봇 패턴 매칭 | `static boolean isBot(String)` | 순수 함수 |
|
||||||
|
| `RateLimitService.tryConsume(key)` | Bucket4j 1 토큰 소비 | `boolean tryConsume(String)` | Bucket 1/min |
|
||||||
|
| `StatsController.recordVisit(req)` (수정) | 봇 + IP 필터 후 카운트 | `@PostMapping("/visit")` | request 헤더 활용 |
|
||||||
|
|
||||||
|
## 8. 흐름
|
||||||
|
|
||||||
|
1. POST `/api/stats/visit` 진입.
|
||||||
|
2. `X-Forwarded-For` 우선 → 없으면 `request.getRemoteAddr()`.
|
||||||
|
3. User-Agent 검사 (`isBot` true면 counted=false).
|
||||||
|
4. IP 레이트 검사 (`tryConsume` false면 counted=false).
|
||||||
|
5. 통과 시 `recordVisit()` 호출.
|
||||||
|
6. 응답 `{ok:true, counted: ...}`.
|
||||||
|
|
||||||
|
## 9. 엣지케이스
|
||||||
|
|
||||||
|
- **여러 IP가 헤더에 chain**: X-Forwarded-For 첫 번째(원본 클라이언트) 사용.
|
||||||
|
- **헤더 위조**: Nginx Ingress 뒤라 외부 위조는 어렵지만, 신뢰는 가정.
|
||||||
|
- **Redis 다운**: Bucket4j Redis 에러 → fail open(즉, counted=true 진행). 통계는 약간 부풀지만 사용자 경험 우선.
|
||||||
|
- **빈 UA**: 봇 판정 안 함 (정상 모바일 앱일 수도).
|
||||||
|
|
||||||
|
## 10. 테스트
|
||||||
|
|
||||||
|
- 수동:
|
||||||
|
```
|
||||||
|
curl -X POST -H "User-Agent: Googlebot/2.1" https://www.tasteby.net/api/stats/visit
|
||||||
|
→ counted=false
|
||||||
|
curl -X POST -H "User-Agent: Mozilla/5.0" https://www.tasteby.net/api/stats/visit
|
||||||
|
→ counted=true
|
||||||
|
(즉시 재호출) → counted=false (rate limit)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안
|
||||||
|
|
||||||
|
- **선택**: Bucket4j + Redis backend + UA 정규식.
|
||||||
|
- **대안 A**: 메모리 LRU만 사용 — 단일 파드는 OK, 멀티 파드는 어뷰즈 가능.
|
||||||
|
- **대안 B**: Nginx Ingress에서 rate limit — 인프라 분리 깔끔. 다만 봇 UA 필터는 ingress nginx 모듈 추가 부담, 어플리케이션 가시성↓.
|
||||||
|
- **대안 C**: 비동기 큐(@Async 또는 Redis INCR) — DB write 부담 해소. 본 이슈는 어뷰즈 차단이 우선이라 후속으로 분리.
|
||||||
|
|
||||||
|
## 12. 미해결 질문
|
||||||
|
|
||||||
|
- UU(쿠키 기반 고유 방문자) — 별도 후속 (#274 후속 #337의 잔여 항목으로 분리).
|
||||||
|
- Bucket4j 1분 정책의 적정성 — 운영 1주일 관찰 후 조정.
|
||||||
|
- 봇 UA 화이트리스트(친화적 검색 엔진은 카운트 포함?) — 현재 일괄 skip.
|
||||||
117
docs/design/343-frontend-test-infra/README.md
Normal file
117
docs/design/343-frontend-test-infra/README.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# 설계서: RTL/Jest 인프라 + next/image + ARIA Tabs (#343)
|
||||||
|
|
||||||
|
> **상태**: Approved
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #343 · 부모: #281 (현행화 frontend-review-memo, 09-Done)
|
||||||
|
> · 구현 파일: `frontend/package.json`, `frontend/jest.config.ts` (신규), `frontend/jest.setup.ts` (신규), `frontend/next.config.ts`, `frontend/__tests__/*` (신규), `frontend/src/components/MyReviewsList.tsx`, `frontend/src/components/ReviewSection.tsx`
|
||||||
|
> · 테스트: 본 이슈가 테스트 인프라 자체를 도입
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
|
본 프로젝트는 지금까지 자동화된 단위 테스트가 0건. 누적된 "후속 테스트" 항목이 12+개(StatsService/CacheService/SearchService/Stars/HangulSimilarity 등)이며, 그동안 분리된 후속 이슈를 처리할 인프라가 없어 모두 보류 상태. 본 이슈에서 Jest + RTL 인프라 도입 + next/image 적용 + ARIA Tabs 보강.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
|
||||||
|
- **포함**
|
||||||
|
- Jest 30 + `next/jest` 자동 설정 + `@testing-library/react` + `@testing-library/jest-dom`.
|
||||||
|
- `jest.config.ts`, `jest.setup.ts`, `package.json scripts: test, test:watch`.
|
||||||
|
- 샘플 테스트 3개 — 가장 안전한 순수 함수/단순 컴포넌트로 인프라 검증:
|
||||||
|
- `Stars` 컴포넌트 렌더 + 별점 표시
|
||||||
|
- 기존 `HangulSimilarity` (#348) — 자모/유사도
|
||||||
|
- `BotDetector` (#337) — 봇 UA 패턴
|
||||||
|
- `next.config.ts` `images.remotePatterns`에 Google avatar 도메인(`lh3.googleusercontent.com`) + YouTube thumbnail(`i.ytimg.com`).
|
||||||
|
- `ReviewSection`/`MyReviewsList`의 `<img>` 일부를 `next/image` 또는 명시적 eslint-disable로 정리.
|
||||||
|
- `MyReviewsList` 탭에 WAI-ARIA Tabs 패턴(role=tablist/tab/aria-selected/aria-controls).
|
||||||
|
- **제외 (후속)**
|
||||||
|
- 백엔드 JUnit 테스트 인프라 (별도 큰 작업).
|
||||||
|
- E2E (Playwright) 도입.
|
||||||
|
- CI 통합 (GitHub Actions 또는 OCI DevOps).
|
||||||
|
- 모든 컴포넌트 테스트 — 점진적으로 추가.
|
||||||
|
- 모든 `<img>` → `next/image` 전수 교체 — 점진적.
|
||||||
|
|
||||||
|
## 3. 인수조건
|
||||||
|
|
||||||
|
- [ ] `npm test`가 단일 명령으로 동작 (0건 → 샘플 3개 통과).
|
||||||
|
- [ ] `npm run build`가 회귀 없이 통과.
|
||||||
|
- [ ] `next.config.ts`에 `remotePatterns` 설정.
|
||||||
|
- [ ] `ReviewSection`의 user_avatar_url `<img>`에 `next/image` 또는 eslint-disable 주석.
|
||||||
|
- [ ] `MyReviewsList` 탭이 `role="tablist"`/`role="tab"`/`aria-selected`/`aria-controls`/`tabIndex` 설정.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
- Next.js 16.1.6 + Turbopack.
|
||||||
|
- `next/jest`는 SWC/Babel 자동 통합. Turbopack 빌드와는 분리(테스트만 Jest 별도).
|
||||||
|
- Pretendard/Geist 폰트는 `next/font/local` 사용 → 테스트에선 mock 불필요.
|
||||||
|
- 패널 분리(#329)로 admin 영역은 단위 테스트 도입 더 쉬워짐.
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── package.json
|
||||||
|
│ └── scripts: test, test:watch
|
||||||
|
├── jest.config.ts (next/jest createNextJestConfig 사용)
|
||||||
|
├── jest.setup.ts (@testing-library/jest-dom 확장 matchers)
|
||||||
|
├── __tests__/
|
||||||
|
│ ├── Stars.test.tsx
|
||||||
|
│ ├── HangulSimilarity.test.ts (자체 구현은 backend Java, TS 포팅은 미적용 → 다른 순수 함수로 대체)
|
||||||
|
│ └── BotDetector.test.ts (마찬가지 — backend → TS 동등 포팅 불가)
|
||||||
|
└── (대안) 프론트 측 순수 함수:
|
||||||
|
├── lib/cuisine-icons.ts 의 getPhosphorCuisineIcon
|
||||||
|
├── components/Stars 의 0.5 단위 렌더
|
||||||
|
└── i18n/config.ts 의 isLocale/detectBrowserLocale
|
||||||
|
```
|
||||||
|
|
||||||
|
→ 백엔드 Java 코드는 TS 테스트로 검증 불가. 프론트 측 순수 함수 3개로 대체:
|
||||||
|
- `Stars` 렌더 (RTL component test)
|
||||||
|
- `i18n/config.ts` `isLocale` (pure)
|
||||||
|
- `i18n/config.ts` `detectBrowserLocale` (navigator mock)
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
|
||||||
|
`__tests__/*.test.{tsx,ts}` — Jest 표준 컨벤션.
|
||||||
|
|
||||||
|
## 7. 함수 명세
|
||||||
|
|
||||||
|
| 단위 | 책임 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| `jest.config.ts` | `createJestConfig(customConfig)` + moduleNameMapper `@/*` | next/jest |
|
||||||
|
| `jest.setup.ts` | `import "@testing-library/jest-dom"` | 확장 matchers |
|
||||||
|
| `Stars.test.tsx` | 별점 0/2.5/5 렌더, aria-label 확인 | RTL |
|
||||||
|
| `i18n/config.test.ts` | isLocale/detectBrowserLocale | navigator mock |
|
||||||
|
| `MyReviewsList` Tabs 패치 | tablist/tab/aria-selected | role + aria |
|
||||||
|
| `ReviewSection` img → eslint-disable | 최소 변경 | next/image는 후속 |
|
||||||
|
|
||||||
|
## 8. 흐름
|
||||||
|
|
||||||
|
1. `npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event @types/jest`.
|
||||||
|
2. `jest.config.ts` + `jest.setup.ts` 작성.
|
||||||
|
3. `package.json`에 `"test": "jest"` + `"test:watch": "jest --watch"` 추가.
|
||||||
|
4. `__tests__/`에 3개 샘플 테스트.
|
||||||
|
5. `next.config.ts`에 `remotePatterns` 추가.
|
||||||
|
6. `MyReviewsList` Tabs ARIA 보강.
|
||||||
|
7. `ReviewSection`의 `<img>` 라인에 `// eslint-disable-next-line @next/next/no-img-element` (next/image 전환은 후속).
|
||||||
|
8. `npm test` 통과 → `npm run build` 통과.
|
||||||
|
|
||||||
|
## 9. 엣지케이스
|
||||||
|
|
||||||
|
- **Turbopack vs Jest**: 무관 (테스트는 별도 SWC 컴파일).
|
||||||
|
- **CSS modules / globals.css import**: jest.config.ts의 moduleNameMapper로 `\\.(css|scss)$` → `identity-obj-proxy` 대신 next/jest가 자동 처리.
|
||||||
|
- **Next.js Server Components**: 본 프로젝트는 모두 `"use client"` 컴포넌트라 RTL이 통상 동작.
|
||||||
|
|
||||||
|
## 10. 테스트
|
||||||
|
|
||||||
|
자기 자신 — `npm test`가 통과해야 본 이슈 완료.
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안
|
||||||
|
|
||||||
|
- **선택**: `next/jest` + RTL. Next.js 공식 권장.
|
||||||
|
- **대안 A**: Vitest — 더 빠르지만 Next.js 공식 가이드 부재, 본 프로젝트 규모에서 차이 작음.
|
||||||
|
- **대안 B**: Playwright Component Testing — 더 무겁고 E2E 통합 안 됨.
|
||||||
|
- **트레이드오프**: Jest 30 + RTL은 React 19에 호환. 의존성 부담은 dev-only.
|
||||||
|
|
||||||
|
## 12. 미해결 질문
|
||||||
|
|
||||||
|
- CI(테스트 자동 실행) — 본 이슈 범위 밖. OCI DevOps Build Pipeline은 ARM64 미지원 → GitHub Actions 또는 Gitea Actions 후속.
|
||||||
|
- 백엔드 JUnit 테스트 인프라 — 별도 큰 이슈.
|
||||||
|
- E2E (Playwright) — 별도.
|
||||||
80
docs/design/348-name-similarity/README.md
Normal file
80
docs/design/348-name-similarity/README.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# 설계서: isNameSimilar 한국어 자모 분해 + Sørensen-Dice (#348)
|
||||||
|
|
||||||
|
> **상태**: Approved
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #348 · 부모: #332 (이미 close)
|
||||||
|
> · 구현 파일: `backend-java/src/main/java/com/tasteby/util/HangulSimilarity.java` (신규), `backend-java/src/main/java/com/tasteby/controller/RestaurantController.java`
|
||||||
|
|
||||||
|
## 1. 목적
|
||||||
|
|
||||||
|
기존 `isNameSimilar`가 Jaccard-like(문자 set 교집합 비율 ≥ 0.4)로 짧은 한국어 이름에서 오탐 가능. 자모 분해 + Sørensen-Dice bigram으로 정확도 향상.
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
- **포함**
|
||||||
|
- `HangulSimilarity.similarity(a, b)` 유틸 신규
|
||||||
|
- `RestaurantController.isNameSimilar` 호출부를 새 유틸로 교체
|
||||||
|
- **제외 (별도 후속으로 분리)**
|
||||||
|
- DDG → 정식 검색 API 전환 (외부 API 결정 + 비용/계약 필요)
|
||||||
|
- DTO RestaurantUpdateDTO + @Valid 표준화 (#332 화이트리스트 set으로 SQL 측 가드 확보)
|
||||||
|
- UNIQUE(google_place_id) 제약 강화 — DB 중복 정리 선행 필요(현재 10+건 중복 확인)
|
||||||
|
|
||||||
|
## 3. 인수조건
|
||||||
|
|
||||||
|
- [ ] `HangulSimilarity.similarity(a, b)` 0.0~1.0 반환 (1.0=동일)
|
||||||
|
- [ ] 한국어 음절을 Unicode NFD로 자모 분해(초성·중성·종성)
|
||||||
|
- [ ] 분해 후 bigram 기반 Sørensen-Dice 계수 계산
|
||||||
|
- [ ] 빈 문자열 안전 처리 (둘 다 비면 0.0, 한쪽만 비면 0.0)
|
||||||
|
- [ ] `RestaurantController.isNameSimilar` 임계값 0.45로 호출 (Jaccard 0.4와 유사 보수성)
|
||||||
|
- [ ] 회귀 없음 — 기존 정상 매칭 시나리오 통과
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
- Java 21 `Normalizer.normalize(Form.NFD)` 활용.
|
||||||
|
- 한글 음절(가-힣) NFD → 초성(ㄱ-ㅎ 호환자모 또는 조합자모) + 중성 + 종성.
|
||||||
|
- 영문/숫자는 그대로 통과.
|
||||||
|
- Sørensen-Dice: `2 * |A ∩ B| / (|A| + |B|)` — bigram 다중집합(multiset) 기준.
|
||||||
|
|
||||||
|
## 5. 함수 명세
|
||||||
|
|
||||||
|
| 함수 | 책임 | 시그니처 |
|
||||||
|
|------|------|---------|
|
||||||
|
| `decomposeHangul(s)` | NFD 자모 분해 + 공백/구두점 제거 + 소문자화 | `static String decompose(String)` |
|
||||||
|
| `bigrams(s)` | 2글자 bigram 리스트 | `static List<String> bigrams(String)` |
|
||||||
|
| `similarity(a, b)` | Sørensen-Dice 0.0~1.0 | `static double similarity(String, String)` |
|
||||||
|
|
||||||
|
## 6. 흐름
|
||||||
|
|
||||||
|
1. 두 이름을 `decompose`로 자모 분해 + 정규화.
|
||||||
|
2. 각 분해 결과를 `bigrams`로 분해.
|
||||||
|
3. multiset 교집합 크기 카운트.
|
||||||
|
4. `2 * common / (sizeA + sizeB)`.
|
||||||
|
|
||||||
|
## 7. 엣지케이스
|
||||||
|
|
||||||
|
- **둘 다 빈 문자열**: 0.0 반환.
|
||||||
|
- **bigram 1개 이하**: 두 문자열 같으면 1.0, 아니면 0.0.
|
||||||
|
- **포함 관계**: 기존 코드의 `a.contains(b) || b.contains(a)` 단축 평가 유지 (1.0 반환).
|
||||||
|
- **혼합(한영)**: NFD가 한글만 분해 → 영문은 그대로. bigram 계산은 동일하게 동작.
|
||||||
|
|
||||||
|
## 8. 테스트 (수동)
|
||||||
|
|
||||||
|
```
|
||||||
|
similarity("스타벅스 강남", "스타벅스 강남점") → ≥ 0.85
|
||||||
|
similarity("스타벅스 강남", "스타벅스 종로") → ≥ 0.55, < 0.85
|
||||||
|
similarity("스타벅스", "맥도날드") → < 0.20
|
||||||
|
similarity("PIZZAHUT", "피자헛") → 한글 + 영문 혼재 가드 통과
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 리스크 & 대안
|
||||||
|
|
||||||
|
- **선택**: NFD 분해 + bigram Sørensen-Dice. Java 표준 라이브러리만 사용.
|
||||||
|
- **대안 A**: Apache Commons Text `JaroWinklerSimilarity` — 라이브러리 추가 부담.
|
||||||
|
- **대안 B**: Hangul.js류 라이브러리 — Java 포팅 없음.
|
||||||
|
- **대안 C**: Levenshtein 거리 — 자모 분해와 결합 시 좋으나 구현 복잡.
|
||||||
|
|
||||||
|
## 10. 미해결 질문 / 분리된 후속
|
||||||
|
|
||||||
|
- DDG → 정식 검색 API: Naver Search API 또는 Google Custom Search (외부 API 결정 + 비용 검토 필요) — 별도 신규 이슈
|
||||||
|
- DTO RestaurantUpdateDTO + @Valid: #332 set 화이트리스트로 1차 가드. 본격 DTO는 큰 변경 — 별도 신규 이슈
|
||||||
|
- UNIQUE(google_place_id) 제약: 현재 10+건 중복. 데이터 정리(병합/삭제) 선행 → 별도 신규 이슈
|
||||||
141
docs/design/352-i18n-skeleton/README.md
Normal file
141
docs/design/352-i18n-skeleton/README.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# 설계서: i18n 뼈대 — ko/en/ja/es (#352)
|
||||||
|
|
||||||
|
> **상태**: Approved
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #352
|
||||||
|
> · 구현 파일: `frontend/package.json`, `frontend/next.config.ts`, `frontend/src/i18n/*` (신규), `frontend/src/messages/{ko,en,ja,es}.json` (신규), `frontend/src/components/LanguageSwitcher.tsx` (신규)
|
||||||
|
> · 테스트: 수동 (언어 전환 + ko fallback)
|
||||||
|
|
||||||
|
## 1. 목적
|
||||||
|
|
||||||
|
장기적으로 영어/일본어/스페인어 시장으로 확장 가능하도록 i18n 뼈대 구축. 본 이슈는 *뼈대*만 — 약 30개 키 초기 번역.
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
- **포함**
|
||||||
|
- `next-intl` 라이브러리 도입 (Next.js 16 App Router 권장).
|
||||||
|
- 4개 로케일: `ko, en, ja, es`. 기본 `ko`, fallback `ko`.
|
||||||
|
- 메시지 디렉토리 `src/messages/{ko,en,ja,es}.json`.
|
||||||
|
- Provider (`<NextIntlClientProvider>`) 루트 layout 적용.
|
||||||
|
- `LanguageSwitcher` 컴포넌트 (헤더 우측).
|
||||||
|
- 로케일 저장: localStorage `tasteby_locale`.
|
||||||
|
- 초기 번역 키 ~30개: 헤더(로그인/검색/메뉴) + 주요 액션(저장/취소/삭제/확인) + 페이지 제목.
|
||||||
|
- **제외**
|
||||||
|
- URL 라우팅 i18n (`/ko/...`).
|
||||||
|
- SEO meta tags i18n.
|
||||||
|
- 식당명/리뷰 등 사용자 콘텐츠 번역.
|
||||||
|
- 어드민 페이지(운영자만 사용, 한국어 유지).
|
||||||
|
- 식당 카드/상세 시트 전체 키 추출 (점진).
|
||||||
|
|
||||||
|
## 3. 인수조건
|
||||||
|
|
||||||
|
- [ ] `npm i next-intl` + 4개 메시지 파일 생성.
|
||||||
|
- [ ] `tasteby_locale`이 localStorage에 있으면 사용, 없으면 브라우저 언어 감지(`navigator.language`) → 매칭 안되면 `ko`.
|
||||||
|
- [ ] 헤더 우측 LanguageSwitcher 드롭다운(국기 + 코드).
|
||||||
|
- [ ] 초기 번역 키 약 30개 — 4개 언어 모두 채움.
|
||||||
|
- [ ] 미번역 키는 `ko` fallback (에러 없이).
|
||||||
|
- [ ] 빌드/배포 회귀 없음.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
- Next.js 16 + App Router + `"use client"` 컴포넌트 다수.
|
||||||
|
- 기존 `auth-context`처럼 i18n도 React Context 패턴.
|
||||||
|
- `next-intl`은 server/client 모두 지원, 본 프로젝트는 client-side switching이라 `NextIntlClientProvider` 중심.
|
||||||
|
- URL 라우팅 변경 없이 단순 메시지만 교체(낮은 비용).
|
||||||
|
- 폰트: Pretendard Variable이 한국어/영어 잘 표시, 일본어는 시스템 폰트 fallback OK, 스페인어는 라틴 문자라 OK.
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── i18n/
|
||||||
|
│ │ ├── config.ts ← 로케일 목록/기본값 상수
|
||||||
|
│ │ ├── LocaleProvider.tsx ← NextIntlClientProvider wrap + localStorage 저장
|
||||||
|
│ │ └── useTranslations.ts ← (next-intl 재export)
|
||||||
|
│ ├── messages/
|
||||||
|
│ │ ├── ko.json ← 기본
|
||||||
|
│ │ ├── en.json
|
||||||
|
│ │ ├── ja.json
|
||||||
|
│ │ └── es.json
|
||||||
|
│ ├── components/
|
||||||
|
│ │ └── LanguageSwitcher.tsx ← 헤더용
|
||||||
|
│ └── app/
|
||||||
|
│ └── layout.tsx ← LocaleProvider로 감싸기
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
|
||||||
|
### 메시지 키 (초기 ~30)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"search": "검색", "login": "로그인", "logout": "로그아웃",
|
||||||
|
"menu": "메뉴", "myReviews": "내 리뷰", "favorites": "즐겨찾기"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "저장", "cancel": "취소", "delete": "삭제",
|
||||||
|
"edit": "수정", "confirm": "확인", "close": "닫기",
|
||||||
|
"loading": "로딩 중...", "submit": "제출"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"title": "필터", "cuisine": "음식 종류", "price": "가격대",
|
||||||
|
"region": "지역", "channel": "채널", "reset": "초기화"
|
||||||
|
},
|
||||||
|
"restaurant": {
|
||||||
|
"rating": "평점", "address": "주소", "phone": "전화",
|
||||||
|
"website": "웹사이트", "closed": "폐업", "tempClosed": "임시휴업"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"title": "리뷰", "write": "리뷰 작성", "noReviews": "아직 리뷰가 없습니다"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
총 5개 카테고리 × 평균 6개 = 30개 키.
|
||||||
|
|
||||||
|
## 7. 함수 명세
|
||||||
|
|
||||||
|
| 함수/컴포넌트 | 책임 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| `i18n/config.ts` | LOCALES, DEFAULT_LOCALE 상수 | 단순 |
|
||||||
|
| `LocaleProvider.tsx` | NextIntlClientProvider wrap + 메시지 동적 로딩 + localStorage 동기화 | client |
|
||||||
|
| `LanguageSwitcher.tsx` | 헤더 드롭다운 (국기 + 코드) | client, 44px 터치 |
|
||||||
|
| `messages/<lang>.json` | 키 → 텍스트 | flat or nested |
|
||||||
|
|
||||||
|
## 8. 흐름
|
||||||
|
|
||||||
|
1. 사용자 첫 방문 → `tasteby_locale` 없음 → `navigator.language.split('-')[0]`이 LOCALES에 있으면 사용, 아니면 `ko`.
|
||||||
|
2. LocaleProvider가 해당 로케일 메시지 파일 import → NextIntlClientProvider에 전달.
|
||||||
|
3. 컴포넌트는 `useTranslations('header')` 등으로 호출.
|
||||||
|
4. LanguageSwitcher에서 변경 → localStorage 저장 → 페이지 새로고침 또는 state 업데이트.
|
||||||
|
|
||||||
|
## 9. 엣지케이스
|
||||||
|
|
||||||
|
- **메시지 파일 누락 키**: next-intl 기본 동작은 키 자체 표시 + 콘솔 경고. fallback 처리는 messages 명시.
|
||||||
|
- **localStorage 비활성/SSR**: typeof window 체크.
|
||||||
|
- **로케일 코드 대소문자**: 항상 소문자 정규화.
|
||||||
|
- **placeholder 변수**: next-intl ICU 메시지 형식 지원 (`{name}` 등). 초기 키에는 미적용.
|
||||||
|
|
||||||
|
## 10. 테스트
|
||||||
|
|
||||||
|
- 수동:
|
||||||
|
- 한국어 첫 방문 → "검색" 표시.
|
||||||
|
- LanguageSwitcher에서 English → "Search" 표시.
|
||||||
|
- 새로고침 후 영어 유지.
|
||||||
|
- 메시지 누락 키 → 콘솔 경고 + 키 표시.
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안
|
||||||
|
|
||||||
|
- **선택**: `next-intl` (Next.js 16 App Router 권장, ICU 메시지, 활발 유지보수).
|
||||||
|
- **대안 A**: `react-i18next` — 더 일반적이지만 App Router 통합 next-intl이 더 매끄러움.
|
||||||
|
- **대안 B**: 자체 구현 + Context — 의존성 ↓ but 기능/표준화 ↓.
|
||||||
|
- **트레이드오프**: 30개 키는 단순하지만 ICU 메시지(복수형, 성별 등) 필요 시 next-intl 가치 큼.
|
||||||
|
|
||||||
|
## 12. 미해결 질문
|
||||||
|
|
||||||
|
- 식당명/리뷰 콘텐츠 번역 — 사용자 작성이라 자동 번역(LLM)? 별도 정책 결정.
|
||||||
|
- URL 라우팅 i18n (`/en/`) — 후속.
|
||||||
|
- SEO meta tags i18n — 후속.
|
||||||
|
- 어드민 페이지는 운영자 한국어 유지 — 확정.
|
||||||
|
- 통화/날짜 포맷(Intl API) — 후속.
|
||||||
172
docs/design/356-video-relevance-llm/README.md
Normal file
172
docs/design/356-video-relevance-llm/README.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# 설계서: 영상-식당 관련도 LLM 평가로 약한 매칭 자동 숨김 (#356)
|
||||||
|
|
||||||
|
> **상태**: Approved
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #356 · 유사 패턴: #322(식당 LLM 검증, 09-Done) · 부모 영역: #270(영상→식당 추출 파이프라인 현행화, 09-Done)
|
||||||
|
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/VideoRelevanceService.java`(신규), `backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml`, `backend-java/src/main/java/com/tasteby/service/RestaurantService.java`, `backend-java/src/main/java/com/tasteby/controller/AdminVideoRelevanceController.java`(신규), DB 마이그레이션 SQL
|
||||||
|
> · 테스트: 본 범위 밖 (테스트 인프라 #343 도입됨, 후속에서 점진 확장)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
|
식당 상세에 연결된 영상 중 식당과 **본격적으로 관련 없는 약한 언급**(비교 대상, 일반 토픽 중 잠깐 언급, 식당 입점 전 영상 등)이 노이즈로 표시. 실제 케이스 — 파이브가이즈 강남의 영상 7개 중 3건이 약한 매칭(쉐이크쉑 비교 / 미국 비만율 일반 토픽 / 한국 입점 전 미국 여행). LLM 평가로 약한 매칭 자동 숨김.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
|
||||||
|
- **포함**
|
||||||
|
- `video_restaurants` 테이블에 `relevance`, `relevance_reason`, `relevance_evaluated_at` 컬럼 추가.
|
||||||
|
- `VideoRelevanceService` 신규 — LLM 판정 + DB 반영 (`#322` 패턴 모방).
|
||||||
|
- `PipelineService.processExtract` 완료 후 `verifyAsync(linkId)` 호출 — 신규 등록 자동 평가.
|
||||||
|
- `GET /api/restaurants/{id}/videos`: 기본 `relevance = 'strong'`만 응답. `?include_weak=true` 시 모두 포함.
|
||||||
|
- 어드민 API: 단건 재평가 / 일괄 백필 / 수동 토글.
|
||||||
|
- **제외 (별도 후속)**
|
||||||
|
- 어드민 UI(검증 칼럼 / 토글) — `#322`의 RestaurantsPanel UI와 같은 패턴으로 별도 후속.
|
||||||
|
- 프론트 사용자 옵션 UI("약한 매칭도 보기" 토글) — 별도 후속.
|
||||||
|
- LLM 비용 모니터링/메트릭 — 별도.
|
||||||
|
|
||||||
|
## 3. 인수조건
|
||||||
|
|
||||||
|
- [ ] `video_restaurants` 테이블에 `relevance VARCHAR2(16) DEFAULT 'unknown'`, `relevance_reason VARCHAR2(120)`, `relevance_evaluated_at TIMESTAMP` 컬럼 + `idx_vr_relevance` 인덱스.
|
||||||
|
- [ ] 가능한 값: `strong | weak | incidental | unknown` (unknown = 미평가).
|
||||||
|
- [ ] 신규 등록 시 60초 안에 `relevance_evaluated_at` 설정.
|
||||||
|
- [ ] `GET /api/restaurants/{id}/videos` 기본 응답: `relevance IN ('strong','unknown')` (안전한 기본값 = 평가 실패 시 표시).
|
||||||
|
- [ ] `?include_weak=true`: 모두 포함 + `relevance`, `relevance_reason` 필드 동봉.
|
||||||
|
- [ ] 어드민 API:
|
||||||
|
- `GET /api/admin/video-relevance/pending` → 미평가(unknown) 카운트
|
||||||
|
- `POST /api/admin/video-relevance/all?batchSize=10` → 백필
|
||||||
|
- `POST /api/admin/video-relevance/{linkId}/evaluate` → 단건 재평가
|
||||||
|
- `PATCH /api/admin/video-relevance/{linkId}` → 수동 강제 토글 `{relevance, reason}`
|
||||||
|
- [ ] LLM 호출 실패 시 `unknown` 유지 + 로그 (`#322`와 같은 안전 기본값).
|
||||||
|
- [ ] 빌드/배포 회귀 없음.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
- 기존 `OciGenAiService.chat(prompt, maxTokens)` 재사용.
|
||||||
|
- LLM 비용: 영상-식당 페어당 1회 단발. 현재 1,244건 → 백필 시 약 1,244 호출.
|
||||||
|
- `video_restaurants`는 한 영상에 여러 식당, 한 식당에 여러 영상이 m:n 관계.
|
||||||
|
- 같은 페어는 `relevance_evaluated_at`이 NULL 아니면 재평가 안 함 (캐시).
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
PipelineService.processExtract (기존)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RestaurantService.linkVideoRestaurant (video_restaurants INSERT)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
VideoRelevanceService.verifyAsync(linkId) ← #356 신규
|
||||||
|
│ (비동기)
|
||||||
|
▼
|
||||||
|
OciGenAiService.chat(prompt, 120)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
parseRelevance → { relevance: strong|weak|incidental, reason: string }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RestaurantMapper.updateRelevance(linkId, relevance, reason)
|
||||||
|
│
|
||||||
|
▼ (조회 시)
|
||||||
|
RestaurantMapper.findVideoLinks(restaurantId, includeWeak)
|
||||||
|
├ includeWeak=false (기본): WHERE relevance IN ('strong','unknown')
|
||||||
|
└ includeWeak=true: 모두 + relevance/reason 필드 노출
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
|
||||||
|
### DB 마이그레이션
|
||||||
|
```sql
|
||||||
|
ALTER TABLE video_restaurants ADD (
|
||||||
|
relevance VARCHAR2(16) DEFAULT 'unknown' NOT NULL,
|
||||||
|
relevance_reason VARCHAR2(120),
|
||||||
|
relevance_evaluated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_vr_relevance ON video_restaurants(relevance);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 도메인 (`VideoRestaurantLink` 확장은 본 범위 밖 — findVideoLinks는 `resultType="map"`)
|
||||||
|
응답 Map에 키 추가:
|
||||||
|
- `relevance`: `"strong" | "weak" | "incidental" | "unknown"`
|
||||||
|
- `relevance_reason`: `string | null`
|
||||||
|
|
||||||
|
### LLM 응답 스키마
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"relevance": "strong" | "weak" | "incidental",
|
||||||
|
"reason": "20자 이내"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 함수 명세
|
||||||
|
|
||||||
|
| 함수 | 책임 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| `VideoRelevanceService.verifyAsync(linkId)` | 비동기 트리거 | `#322`의 `RestaurantVerifyService.verifyAsync` 유사 |
|
||||||
|
| `VideoRelevanceService.verify(linkId)` | 단건 검증 + DB 반영 | LLM 실패 시 unknown 유지 |
|
||||||
|
| `VideoRelevanceService.verifyAll(batchSize)` | 백필 (식당당 200ms sleep) | |
|
||||||
|
| `VideoRelevanceService.buildPrompt(...)` | 프롬프트 생성 | 식당명·주소·음식·영상 제목·평가 |
|
||||||
|
| `VideoRelevanceService.parseRelevance(raw)` | LLM 응답 → DTO | 파싱 실패 시 unknown 안전 기본값 |
|
||||||
|
| `RestaurantMapper.updateRelevance(linkId, rel, reason)` | DB 갱신 | |
|
||||||
|
| `RestaurantMapper.findVideoLinks(restaurantId, includeWeak)` | 기존 SQL에 WHERE 조건 추가 | |
|
||||||
|
| `AdminVideoRelevanceController` 신규 | 4개 admin endpoint | requireAdmin |
|
||||||
|
|
||||||
|
## 8. 흐름
|
||||||
|
|
||||||
|
### 신규 등록 자동 평가
|
||||||
|
1. `PipelineService.processExtract` → `linkVideoRestaurant` → linkId 획득.
|
||||||
|
2. `VideoRelevanceService.verifyAsync(linkId)` 호출(@Async).
|
||||||
|
3. 별도 스레드: 영상/식당/평가 데이터 조회 → buildPrompt → LLM → parse → DB.
|
||||||
|
|
||||||
|
### 백필
|
||||||
|
1. 어드민 `POST /api/admin/video-relevance/all` 호출.
|
||||||
|
2. `WHERE relevance_evaluated_at IS NULL` 인 link 10개씩 조회 → 순차 검증.
|
||||||
|
3. 식당당 200ms sleep (LLM rate 보호).
|
||||||
|
|
||||||
|
### 프롬프트 예시
|
||||||
|
```
|
||||||
|
다음 YouTube 영상이 이 식당을 어떻게 다루는지 판정하라.
|
||||||
|
|
||||||
|
식당명: {restaurantName}
|
||||||
|
주소: {address}
|
||||||
|
음식: {foodsMentioned}
|
||||||
|
|
||||||
|
영상 제목: {videoTitle}
|
||||||
|
영상 채널: {channelName}
|
||||||
|
영상에 등장한 평가 내용: {evaluation}
|
||||||
|
|
||||||
|
응답 형식(JSON만, 다른 텍스트 없이):
|
||||||
|
{"relevance": "strong"|"weak"|"incidental", "reason": "20자 이내 한국어"}
|
||||||
|
|
||||||
|
가이드:
|
||||||
|
- strong: 영상 본편이 이 식당을 본격 다룸. 방문 리뷰, 메뉴 평가 등.
|
||||||
|
- weak: 영상에서 잠깐 언급, 비교 대상으로만 등장, 다른 식당의 일부로.
|
||||||
|
- incidental: 식당 입점 전 영상에서 단순 언급, 일반 토픽(미국 비만, 환율 등)에서 잠깐.
|
||||||
|
- 판단 모호 시 strong (보수적 — 사용자에게 표시 유지).
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 엣지케이스
|
||||||
|
|
||||||
|
- **LLM 응답 비-JSON**: parseRelevance → unknown 기본값.
|
||||||
|
- **LLM 호출 실패**: unknown 유지 → 다음 백필 재시도.
|
||||||
|
- **영상 데이터 누락(transcript 없음, evaluation 비어있음)**: 프롬프트에 "(미상)" 표기. LLM이 판정 어려우면 strong 보수적.
|
||||||
|
- **동시성**: 같은 linkId verifyAsync 두 번 호출 → idempotent.
|
||||||
|
- **삭제된 영상**: linkId 조회 결과 없으면 no-op.
|
||||||
|
|
||||||
|
## 10. 테스트 (수동)
|
||||||
|
|
||||||
|
- 파이브가이즈 강남 케이스 백필 → 7건 중 3건이 weak/incidental로 마킹되는지 확인.
|
||||||
|
- 공개 API `/api/restaurants/{id}/videos` → 약한 매칭 제외 확인.
|
||||||
|
- `?include_weak=true` → 모두 포함 확인.
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안
|
||||||
|
|
||||||
|
- **선택**: `#322` 동일 패턴 + DB 마이그레이션.
|
||||||
|
- **대안 A**: 사용자가 직접 "약한 매칭도 보기" 토글 → 사용자 결정 부담.
|
||||||
|
- **대안 B**: 추출 단계에서 한 번에 판정 → 비용 ↓이지만 ExtractorService 비대.
|
||||||
|
- **트레이드오프**: 단발 LLM 평가는 비용 합리적. false positive는 어드민 수동 토글 + `unknown` 안전 기본값으로 보완.
|
||||||
|
|
||||||
|
## 12. 미해결 질문
|
||||||
|
|
||||||
|
- 임계값(weak/incidental 둘 다 숨김 vs incidental만 숨김) — 현재는 둘 다 숨김.
|
||||||
|
- 영상 자막 전체를 LLM에 보낼지 vs 평가 텍스트만 → 비용/정확도 트레이드오프. 현재는 evaluation만(짧음).
|
||||||
|
- 사용자에게 "약한 매칭도 보기" UI → 별도 후속.
|
||||||
|
- 어드민 UI — 별도 후속 (#322 패턴 모방).
|
||||||
114
docs/design/357-web-search-api/README.md
Normal file
114
docs/design/357-web-search-api/README.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# 설계서: DDG HTML 파싱 → 정식 검색 API 전환 (#357)
|
||||||
|
|
||||||
|
> **상태**: Approved
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #357 · 부모: #348(이름 유사도, 09-Done) · 관련: searchTabling/searchCatchtable
|
||||||
|
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/WebSearchService.java`(신규), `backend-java/src/main/java/com/tasteby/controller/RestaurantController.java`, `backend-java/src/main/resources/application.yml`, `k8s/secrets.yaml.template`
|
||||||
|
> · 테스트: 본 범위 밖 (수동 — Tabling/Catchtable 매핑 SSE에서 확인)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
|
`searchDuckDuckGo`는 html.duckduckgo.com을 정규식으로 긁어 Tabling/Catchtable URL을 추출. 봇 차단/HTML 구조 변경 시 대량 `NONE` 마킹 위험. 정식 검색 API로 교체해 안정성/정확도 확보.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
|
||||||
|
- **포함**
|
||||||
|
- `WebSearchService` 신규 — Naver Search webkr.json 우선, 미설정/실패 시 DDG 폴백.
|
||||||
|
- `RestaurantController.searchTabling/searchCatchtable` 내부 호출을 새 서비스로 교체.
|
||||||
|
- `application.yml` + `k8s/secrets.yaml.template`에 `NAVER_CLIENT_ID/SECRET` 항목 추가.
|
||||||
|
- **제외 (별도 후속)**
|
||||||
|
- Kakao Local API 검색 (식당 검색 전용이라 사이트 URL 매칭 부적합).
|
||||||
|
- Google Custom Search Engine (비용 + 무료 100/day 제약).
|
||||||
|
- 결과 캐시/메트릭 — 후속.
|
||||||
|
|
||||||
|
## 3. 인수조건
|
||||||
|
|
||||||
|
- [ ] `NAVER_CLIENT_ID/SECRET` 환경변수 등록 시 Naver Search webkr.json 호출.
|
||||||
|
- [ ] 미설정 또는 5xx/timeout 시 DDG로 자동 폴백 — 회귀 없음.
|
||||||
|
- [ ] 응답 형식: 기존 `List<Map<String, Object>>` 유지 — `{title, url}` 키.
|
||||||
|
- [ ] URL 패턴 필터 동일 동작 (예: `tabling.co.kr/restaurant/`, `tabling.co.kr/place/`).
|
||||||
|
- [ ] 빌드/배포 회귀 없음.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
- Naver Search 무료 한도: 일 25,000건. Tabling/Catchtable 매핑 백필이 1,200 식당 × 2회 ≈ 2,400건 1회성 → 한도 내.
|
||||||
|
- Naver는 `site:` 연산자 미지원 — 결과 URL 패턴 필터링으로 대체.
|
||||||
|
- `WebClient` 또는 `HttpClient` — 기존 `HttpClient`(static) 재사용.
|
||||||
|
- 키 미설정 환경(dev local 일부)에서도 DDG 폴백으로 동작 보장.
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
RestaurantController.searchTabling(name)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
WebSearchService.search(query, urlPatterns)
|
||||||
|
│
|
||||||
|
├─ NAVER_CLIENT_ID 설정 시
|
||||||
|
│ └─ Naver webkr.json → URL 패턴 필터 → 결과
|
||||||
|
│ (실패/0건 시 ▼)
|
||||||
|
└─ DDG html.duckduckgo.com → 정규식 파싱 → URL 패턴 필터
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 데이터 모델
|
||||||
|
|
||||||
|
### 응답 (기존 유지)
|
||||||
|
```json
|
||||||
|
[ { "title": "스타벅스 강남대로점", "url": "https://app.catchtable.co.kr/dining/12345" } ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naver 응답 → 변환
|
||||||
|
```json
|
||||||
|
{ "items": [ { "title": "<b>스타벅스</b> 강남점", "link": "https://..." } ] }
|
||||||
|
```
|
||||||
|
- `title`은 `<b>` 태그 제거 후 사용.
|
||||||
|
- `link`가 urlPatterns 중 하나에 매칭되면 결과에 포함.
|
||||||
|
|
||||||
|
## 7. 함수 명세
|
||||||
|
|
||||||
|
| 함수 | 책임 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| `WebSearchService.search(query, urlPatterns)` | 외부 진입점 | Naver 우선 → DDG 폴백 |
|
||||||
|
| `WebSearchService.searchNaver(...)` | Naver Search webkr.json 호출 + 필터 | 키 미설정 시 즉시 빈 결과 |
|
||||||
|
| `WebSearchService.searchDdg(...)` | 기존 DDG 로직 이관 | RestaurantController에서 옮김 |
|
||||||
|
| `WebSearchService.stripTags(s)` | `<b>...</b>` 제거 | |
|
||||||
|
| `WebSearchService.matchesPattern(url, patterns)` | URL 패턴 매칭 | `urlPatterns.length == 0` 면 모두 통과 |
|
||||||
|
|
||||||
|
## 8. 흐름
|
||||||
|
|
||||||
|
### 정상
|
||||||
|
1. `searchTabling(name)` → `webSearchService.search("스타벅스", ["tabling.co.kr/restaurant/", "tabling.co.kr/place/"])`.
|
||||||
|
2. Naver 호출 → 200, items 30 → URL 패턴 매칭 0~N건 추출.
|
||||||
|
3. 0건이면 DDG 폴백, 그래도 0건이면 빈 리스트.
|
||||||
|
|
||||||
|
### 폴백
|
||||||
|
- Naver 5xx / IOException / 키 미설정 → DDG.
|
||||||
|
|
||||||
|
### 키 등록 (운영자 작업)
|
||||||
|
1. https://developers.naver.com/apps/#/list 검색 앱 등록.
|
||||||
|
2. `NAVER_CLIENT_ID`, `NAVER_CLIENT_SECRET` 발급.
|
||||||
|
3. dev: `.env`에 추가, prod: `k8s/secrets.yaml`에 추가 + `kubectl apply`.
|
||||||
|
|
||||||
|
## 9. 엣지케이스
|
||||||
|
|
||||||
|
- **키 미설정**: searchNaver 즉시 빈 리스트 → DDG 폴백 (회귀 없음).
|
||||||
|
- **Naver rate limit (429)**: DDG 폴백.
|
||||||
|
- **DDG도 막힘**: 빈 리스트 반환 → 호출자(매핑 SSE)에서 `NONE` 처리 — 기존 동작 동일.
|
||||||
|
- **URL 패턴 빈 배열**: 패턴 매칭 스킵, 모든 결과 반환 (API 일반화 대비).
|
||||||
|
|
||||||
|
## 10. 테스트 (수동)
|
||||||
|
|
||||||
|
- Dev: `NAVER_CLIENT_ID/SECRET` 등록 → 어드민 `bulkTabling` SSE 실행 → 매칭율 비교 (이전 DDG 대비 ≥).
|
||||||
|
- 키 일부 제거 → DDG 폴백 동작 확인.
|
||||||
|
|
||||||
|
## 11. 리스크 & 대안
|
||||||
|
|
||||||
|
- **선택**: Naver primary + DDG fallback. 한국 식당 정확도 + 무료 한도 + 폴백 안정성.
|
||||||
|
- **대안 A**: Kakao Local Search — 식당 정보 직접 검색은 가능하지만 Tabling/Catchtable URL 매핑 부적합.
|
||||||
|
- **대안 B**: Google CSE — 비용/한도 제약.
|
||||||
|
- **트레이드오프**: Naver 응답 정확도가 압도적이지만 키 발급 운영자 작업 필요. 폴백으로 회귀 0 보장.
|
||||||
|
|
||||||
|
## 12. 미해결 질문
|
||||||
|
|
||||||
|
- Naver의 인덱스 신선도 vs Tabling/Catchtable 최신 입점 식당 미반영 가능성 — 백필 주기 후속.
|
||||||
|
- 결과 캐시(같은 식당 재호출) — 후속.
|
||||||
75
docs/design/358-restaurant-update-dto/README.md
Normal file
75
docs/design/358-restaurant-update-dto/README.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# 설계서: RestaurantUpdateDTO + @Valid 표준화 (#358)
|
||||||
|
|
||||||
|
> **상태**: Approved
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #358 · 부모: #348(09-Done) · 관련: #332(화이트리스트 1차)
|
||||||
|
> · 구현 파일: `backend-java/src/main/java/com/tasteby/dto/RestaurantUpdateDTO.java`(신규), `backend-java/src/main/java/com/tasteby/controller/RestaurantController.java`
|
||||||
|
> · 테스트: 본 범위 밖 (수동 — 어드민 식당 편집 동작 확인)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
|
`#332`에서 Set 화이트리스트로 1차 가드 적용했지만, 타입 안전성·validation·API 명세는 여전히 `Map<String, Object>`로 흐릿함. 본격 DTO 표준화로 잘못된 입력 자동 거부 + 명세 명확화.
|
||||||
|
|
||||||
|
## 2. 범위 (Scope)
|
||||||
|
|
||||||
|
- **포함**
|
||||||
|
- `RestaurantUpdateDTO` record — 화이트리스트 14필드 모두 Optional(null 시 미변경).
|
||||||
|
- `@Valid` + Bean Validation 어노테이션 적용 (`@Size`, `@Pattern`, `@DecimalMin/Max`, `@Min`).
|
||||||
|
- Controller `PUT /api/restaurants/{id}` 시그니처: `Map → RestaurantUpdateDTO`.
|
||||||
|
- DTO → Map 변환(`toFieldMap()`) — Service 계층은 그대로 (재작업 0).
|
||||||
|
- 잘못된 입력 시 400 자동 응답 (Spring 기본 `MethodArgumentNotValidException`).
|
||||||
|
- **제외 (별도 후속)**
|
||||||
|
- `tabling-url` / `catchtable-url` PUT 엔드포인트 — 단일 필드라 현행 유지.
|
||||||
|
- PATCH 시멘틱 (부분 업데이트) — 현재 PUT이 부분 업데이트 의미로 사용 중.
|
||||||
|
|
||||||
|
## 3. 인수조건
|
||||||
|
|
||||||
|
- [ ] 모든 화이트리스트 필드 record에 등재 + null 가능.
|
||||||
|
- [ ] `name`: `@Size(min=1, max=200)`.
|
||||||
|
- [ ] `website`/`tabling_url`/`catchtable_url`: `@Pattern(http(s)://... | "NONE" | "")`.
|
||||||
|
- [ ] `latitude`: `@DecimalMin("-90.0") @DecimalMax("90.0")`.
|
||||||
|
- [ ] `longitude`: `@DecimalMin("-180.0") @DecimalMax("180.0")`.
|
||||||
|
- [ ] `rating`: `@DecimalMin("0.0") @DecimalMax("5.0")`.
|
||||||
|
- [ ] `rating_count`: `@Min(0)`.
|
||||||
|
- [ ] `price_range`: `@Min(1) @Max(5)`.
|
||||||
|
- [ ] 잘못된 입력 → HTTP 400 자동 응답.
|
||||||
|
- [ ] 기존 동작 회귀 없음 (geocode/cache flush 흐름 동일).
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
- `spring-boot-starter-validation` 이미 의존성 등록됨.
|
||||||
|
- record + Bean Validation: 컴파일 시 어노테이션 인식 OK.
|
||||||
|
- Jackson SNAKE_CASE 매핑 유지: `cuisine_type`, `tabling_url` 등.
|
||||||
|
- `null`은 "변경 없음" 시그널 — `toFieldMap()`에서 제외.
|
||||||
|
|
||||||
|
## 5. 함수 명세
|
||||||
|
|
||||||
|
| 함수 | 책임 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| `RestaurantUpdateDTO` (record) | 입력 표면 | 14 필드, 모두 nullable |
|
||||||
|
| `RestaurantUpdateDTO.toFieldMap()` | null 제외 Map 변환 | Service `update` 시그니처 유지 |
|
||||||
|
| `RestaurantController.update(...)` | DTO 받음 + geocode 분기 | `@Valid @RequestBody RestaurantUpdateDTO` |
|
||||||
|
|
||||||
|
## 6. 흐름
|
||||||
|
|
||||||
|
1. 클라이언트 → `PUT /api/restaurants/{id}` JSON.
|
||||||
|
2. Spring 역직렬화 + Bean Validation. 실패 시 400 자동.
|
||||||
|
3. `dto.toFieldMap()` → null 제외.
|
||||||
|
4. 기존 geocode 분기 + `restaurantService.update(id, fieldMap)`.
|
||||||
|
|
||||||
|
## 7. 엣지케이스
|
||||||
|
|
||||||
|
- **모든 필드 null**: `toFieldMap()` 빈 Map → no-op (현행 유지).
|
||||||
|
- **`tabling_url = "NONE"` / 빈 문자열**: Pattern에 포함 → 통과.
|
||||||
|
- **숫자 범위 위반**: 400.
|
||||||
|
- **알 수 없는 필드 (예: `xxx`)**: Jackson 기본은 무시 (mapper 설정 유지) → 안전.
|
||||||
|
|
||||||
|
## 8. 리스크 & 대안
|
||||||
|
|
||||||
|
- **선택**: record + Bean Validation. 코드 최소.
|
||||||
|
- **대안 A**: class + setter. 보일러플레이트 다수.
|
||||||
|
- **대안 B**: 개별 PATCH endpoint per 필드. 표면 폭증.
|
||||||
|
|
||||||
|
## 9. 미해결 질문
|
||||||
|
|
||||||
|
- bulkUpdate (batch) 도입 시 별도 DTO — 후속.
|
||||||
47
docs/design/359a-duplicate-place-id-view/README.md
Normal file
47
docs/design/359a-duplicate-place-id-view/README.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 설계서: google_place_id 중복 조회 API (#359 1단계)
|
||||||
|
|
||||||
|
> **상태**: Approved
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||||
|
> **추적성** — Redmine: #359 · 1단계(조회 전용, 위험 0). 2단계(자동 병합) / 3단계(UNIQUE)는 별도 PR.
|
||||||
|
> · 구현 파일: `backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml`, `backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java`, `backend-java/src/main/java/com/tasteby/service/RestaurantService.java`, `backend-java/src/main/java/com/tasteby/controller/AdminRestaurantController.java`
|
||||||
|
> · 테스트: 본 범위 밖 (수동 — admin token으로 호출).
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
|
같은 `google_place_id`에 다중 식당이 매핑된 경우 운영자가 어떤 것을 유지/병합할지 결정 필요. 본 단계는 **조회만** — 그룹과 후보 식당을 메타데이터(연결된 영상/리뷰/메모 수)와 함께 보여줘 의사결정 자료 제공.
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
- 포함: `GET /api/admin/restaurants/duplicates/place-id` — 운영자만, 그룹별 식당 + 카운트 동봉.
|
||||||
|
- 제외 (별도 PR): 병합/삭제, UNIQUE constraint.
|
||||||
|
|
||||||
|
## 3. 인수조건
|
||||||
|
|
||||||
|
- [ ] requireAdmin 보호.
|
||||||
|
- [ ] 응답 구조: `[{ google_place_id, items: [{ id, name, address, created_at, video_count, review_count, memo_count, hidden }] }]`.
|
||||||
|
- [ ] 그룹은 `COUNT(*) > 1` 만 반환.
|
||||||
|
|
||||||
|
## 4. SQL
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT r.id, r.google_place_id, r.name, r.address,
|
||||||
|
TO_CHAR(r.created_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS created_at,
|
||||||
|
r.hidden,
|
||||||
|
(SELECT COUNT(*) FROM video_restaurants vr WHERE vr.restaurant_id = r.id) AS video_count,
|
||||||
|
(SELECT COUNT(*) FROM reviews rv WHERE rv.restaurant_id = r.id) AS review_count,
|
||||||
|
(SELECT COUNT(*) FROM memos mm WHERE mm.restaurant_id = r.id) AS memo_count
|
||||||
|
FROM restaurants r
|
||||||
|
WHERE r.google_place_id IN (
|
||||||
|
SELECT google_place_id FROM restaurants
|
||||||
|
WHERE google_place_id IS NOT NULL
|
||||||
|
GROUP BY google_place_id HAVING COUNT(*) > 1
|
||||||
|
)
|
||||||
|
ORDER BY r.google_place_id, r.created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
Service 계층에서 google_place_id로 그룹핑하여 응답 구조 변환.
|
||||||
|
|
||||||
|
## 5. 엣지케이스
|
||||||
|
|
||||||
|
- 중복 0건 → 빈 배열.
|
||||||
|
- 누군가가 google_place_id를 동시에 변경 중 → 다음 호출에서 반영 (캐시 X).
|
||||||
112
docs/design/363-map-sdk-branch/README.md
Normal file
112
docs/design/363-map-sdk-branch/README.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# 설계서: 메인 지도 탭 SDK 국내/해외 분기 (#363)
|
||||||
|
|
||||||
|
> **상태**: Approved
|
||||||
|
> **작성**: [AI] Architect · **최종수정**: 2026-06-16
|
||||||
|
> **추적성** — Redmine: #363 · 부모: v0.1.51 1단계(외부 링크 분기) · 관련: MapView.tsx, mobile nearby
|
||||||
|
> · 구현 파일: `frontend/src/components/MapView.tsx`(dispatcher), `frontend/src/components/GoogleMapView.tsx`(rename from 기존 MapView 내용), `frontend/src/components/NaverMapView.tsx`(신규), `frontend/src/lib/map-utils.ts`(공용 헬퍼)
|
||||||
|
> · 테스트: 본 범위 밖 (수동 — dev 브라우저 검증)
|
||||||
|
|
||||||
|
## 1. 목적 (Why)
|
||||||
|
|
||||||
|
현재 MapView는 `@vis.gl/react-google-maps` 단일 사용. 한국 식당은 네이버 지도가 지번/도로명/상호/길찾기에서 압도적으로 정확. 메인 지도 탭 자체를 국내/해외 분기.
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
- 포함: MapView를 dispatcher로 전환, 좌표 기반 자동 분기(KR bbox), 네이버 키 미설정 시 GoogleMap fallback.
|
||||||
|
- 제외 (별도 후속): 사용자 강제 토글 UI, mixed 화면(한국+해외 동시) 최적화, 모바일 nearby도 동일 분기는 1차 적용 후 검토.
|
||||||
|
|
||||||
|
## 3. 인수조건
|
||||||
|
|
||||||
|
- [ ] `NEXT_PUBLIC_NAVER_MAP_CLIENT_ID` 환경변수 설정 + 화면 중심이 KR bbox 안이면 NaverMap 렌더.
|
||||||
|
- [ ] 키 미설정 또는 화면이 KR 밖이면 GoogleMap 렌더 (현행 동일).
|
||||||
|
- [ ] Supercluster + 클러스터/단일 마커 표시, 클릭 → onSelectRestaurant 콜백 동일.
|
||||||
|
- [ ] flyTo, onBoundsChanged, 내 위치, 채널 색상 동일하게 동작.
|
||||||
|
- [ ] 빌드/타입 회귀 없음.
|
||||||
|
|
||||||
|
## 4. 컨텍스트 & 제약
|
||||||
|
|
||||||
|
- 네이버 지도 v3: `https://oapi.map.naver.com/openapi/v3/maps.js?ncpClientId=<ID>` 스크립트 로드.
|
||||||
|
- 네이버 좌표계: 기본 WGS84 (`naver.maps.LatLng(lat, lng)`).
|
||||||
|
- 직접 wrapper 채택 (react-naver-maps 의존성 제거 — 메인터넌스 리스크).
|
||||||
|
- Supercluster는 SDK 독립이라 재사용.
|
||||||
|
- KR bbox: 위도 33~38.7, 경도 124~132. 화면 중심좌표가 안에 있으면 한국.
|
||||||
|
|
||||||
|
## 5. 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
MapView (dispatcher)
|
||||||
|
│
|
||||||
|
├─ 화면 중심 좌표가 KR bbox AND 네이버 키 있음 → NaverMapView
|
||||||
|
│ ├─ <script src=naver maps v3> 동적 로드
|
||||||
|
│ ├─ useEffect: new naver.maps.Map(div, ...)
|
||||||
|
│ ├─ Supercluster로 cluster 계산 → markers div overlay
|
||||||
|
│ └─ flyTo: map.setCenter + setZoom
|
||||||
|
│
|
||||||
|
└─ 그 외 → GoogleMapView (기존 MapView 내용 그대로 이전)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 함수 명세
|
||||||
|
|
||||||
|
| 함수 | 책임 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| `MapView` (dispatcher) | 좌표 기반 분기 | flyTo 또는 첫 마운트 좌표로 판정 |
|
||||||
|
| `GoogleMapView` | 기존 MapView 내용 | rename만, 로직 변경 X |
|
||||||
|
| `NaverMapView` | 신규 — 네이버 지도 + Supercluster + markers | wrapper 직접 |
|
||||||
|
| `useNaverMaps(clientId)` | 스크립트 로드 + ready boolean | 한 번만 로드 |
|
||||||
|
| `isKoreaBounds(lat, lng)` | KR bbox 판정 | map-utils 공용 |
|
||||||
|
|
||||||
|
## 7. 흐름
|
||||||
|
|
||||||
|
1. MapView 마운트 → flyTo or 첫 식당 평균 좌표로 초기 중심 계산.
|
||||||
|
2. KR bbox + 키 있음 → NaverMapView 마운트.
|
||||||
|
3. NaverMapView: `useNaverMaps` 훅으로 v3 스크립트 로드, ready되면 `new naver.maps.Map(divRef, options)` 생성.
|
||||||
|
4. Supercluster로 cluster 계산 → 마커는 absolute positioned div overlay (네이버 OverlayView 또는 자체 좌표 변환).
|
||||||
|
5. 사용자 줌/팬 → bounds_changed 이벤트 → 클러스터 재계산 + onBoundsChanged 콜백.
|
||||||
|
|
||||||
|
## 8. 엣지케이스
|
||||||
|
|
||||||
|
- **네이버 스크립트 로드 실패**: ready=false 유지, dispatcher가 다음 렌더 사이클에서 GoogleMap fallback.
|
||||||
|
- **flyTo가 해외 좌표인데 현재 NaverMap 중**: dispatcher 재판정 → GoogleMap로 교체 (remount).
|
||||||
|
- **mixed 화면(한국+해외 식당)**: 화면 중심 기준 SDK 선택 → 다른 나라 식당은 화면 밖에 있어 무관.
|
||||||
|
- **키 미설정**: 항상 GoogleMap (회귀 0).
|
||||||
|
|
||||||
|
## 9. 리스크 & 대안
|
||||||
|
|
||||||
|
- **선택**: 직접 wrapper. 의존성 최소, 유지보수 자유.
|
||||||
|
- **대안 A**: `react-naver-maps` npm — 빠른 시작이지만 메인터넌스 상태 불확실.
|
||||||
|
- **대안 B**: 단일 SDK(Maplibre + 네이버 타일) — 타일 권리 이슈.
|
||||||
|
- **트레이드오프**: 직접 wrapper는 초기 코드 양 ↑이지만 한 번 만들면 안정.
|
||||||
|
|
||||||
|
## 10. 미해결 질문
|
||||||
|
|
||||||
|
- 한 화면 mixed(국가 경계 근처) 동시 마커 — 후속.
|
||||||
|
- 사용자 토글 UI — 후속.
|
||||||
|
- 모바일 nearby 동일 분기 — 1차 적용 후 결정.
|
||||||
|
|
||||||
|
## 11. 실제 구현 기록 (2026-06-16)
|
||||||
|
|
||||||
|
### 배포 흐름
|
||||||
|
| 버전 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| v0.1.51 | **1단계** — 식당 상세 외부 링크 좌표 기반 분기 (`RestaurantDetail.tsx`) |
|
||||||
|
| v0.1.52 | **2단계** — MapView dispatcher + NaverMapView/GoogleMapView 분리 + Dockerfile/deploy.sh build-arg |
|
||||||
|
| v0.1.53 | **fix**: 인증 파라미터 `ncpClientId` → `ncpKeyId` (NCLOUD 신 정책, 옛 NAVER Developers와 다름) |
|
||||||
|
| v0.1.55–56 | 임시 fallback (운영 일시 GoogleMap, 디버그) |
|
||||||
|
| v0.1.57 | **안정화 + 재활성** — divRef 첫 렌더 누락 fix, ResizeObserver/rAF, try/catch |
|
||||||
|
|
||||||
|
### 운영 진단에서 확인된 사항
|
||||||
|
- NCLOUD Maps Application의 Web 서비스 URL은 **스킴 포함**(`https://...`).
|
||||||
|
- 옛 NAVER Developers와 NCLOUD는 다른 시스템 — Search Application과 Maps Application은 도메인 중복 충돌 없음.
|
||||||
|
- NCLOUD 콘솔의 신규 경로: `Services > Application Services > Maps > Application`.
|
||||||
|
|
||||||
|
### NaverMapView 안정화 핵심 결정사항
|
||||||
|
- **`divRef` 항상 마운트** (early return 제거) — `ready=false` 동안에도 div를 두고 로딩 메시지는 overlay로 표시.
|
||||||
|
- **명시적 `width:100%; height:100%`** + 회색 배경 — 컨테이너 영역이 시각적으로 확인 가능.
|
||||||
|
- **ResizeObserver + requestAnimationFrame**으로 컨테이너 0×0 → 정상 크기 변경 시 `m.refresh(true)`.
|
||||||
|
- **try/catch + `initError` state** — init 실패 시 화면 가시화.
|
||||||
|
|
||||||
|
### 후속 (별도 PR)
|
||||||
|
- 사용자 토글 (네이버/구글 강제 선택) UI.
|
||||||
|
- mixed 화면(국경 근처) 동시 마커.
|
||||||
|
- 모바일 nearby 탭 동일 분기 검토.
|
||||||
|
- 채널 색상/InfoWindow 등 GoogleMapView 수준의 디테일을 NaverMapView에 도입.
|
||||||
766
docs/oke-deploy-howto.md
Normal file
766
docs/oke-deploy-howto.md
Normal file
@@ -0,0 +1,766 @@
|
|||||||
|
# OKE(Oracle Kubernetes Engine) 배포 가이드
|
||||||
|
|
||||||
|
> Spring Boot + Next.js 앱을 OKE에 배포하는 전체 과정을 정리한 문서.
|
||||||
|
> Colima(로컬 ARM64 Docker) → OCIR(이미지 레지스트리) → OKE(K8s 클러스터) 파이프라인 기준.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
|
||||||
|
1. [사전 준비](#1-사전-준비)
|
||||||
|
2. [인프라 아키텍처](#2-인프라-아키텍처)
|
||||||
|
3. [최초 클러스터 설정 (1회성)](#3-최초-클러스터-설정-1회성)
|
||||||
|
4. [K8s 매니페스트 구조](#4-k8s-매니페스트-구조)
|
||||||
|
5. [Dockerfile 작성](#5-dockerfile-작성)
|
||||||
|
6. [배포 스크립트 (deploy.sh)](#6-배포-스크립트-deploysh)
|
||||||
|
7. [일상적인 배포 절차](#7-일상적인-배포-절차)
|
||||||
|
8. [환경별 설정 관리](#8-환경별-설정-관리)
|
||||||
|
9. [도메인 및 SSL 설정](#9-도메인-및-ssl-설정)
|
||||||
|
10. [트러블슈팅](#10-트러블슈팅)
|
||||||
|
11. [유용한 kubectl 명령어](#11-유용한-kubectl-명령어)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 사전 준비
|
||||||
|
|
||||||
|
### 필요한 도구
|
||||||
|
|
||||||
|
| 도구 | 용도 | 설치 |
|
||||||
|
|------|------|------|
|
||||||
|
| **OCI CLI** | OKE 인증, kubeconfig 설정 | `brew install oci-cli` |
|
||||||
|
| **kubectl** | K8s 클러스터 관리 | `brew install kubectl` |
|
||||||
|
| **Colima** | 로컬 ARM64 Docker 빌드 (Docker Desktop 대체) | `brew install colima` |
|
||||||
|
| **Docker CLI** | 이미지 빌드/푸시 | `brew install docker` |
|
||||||
|
| **Helm** | Nginx Ingress, cert-manager 설치 | `brew install helm` |
|
||||||
|
|
||||||
|
### OCI 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# OCI CLI 프로파일 설정 (~/.oci/config)
|
||||||
|
[DEFAULT]
|
||||||
|
user=ocid1.user.oc1..xxxx
|
||||||
|
fingerprint=xx:xx:xx:...
|
||||||
|
tenancy=ocid1.tenancy.oc1..xxxx
|
||||||
|
region=ap-seoul-1
|
||||||
|
key_file=~/.oci/oci_api_key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
### kubeconfig 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# OKE 클러스터 접근 설정
|
||||||
|
oci ce cluster create-kubeconfig \
|
||||||
|
--cluster-id ocid1.cluster.oc1.<region>.<cluster-id> \
|
||||||
|
--file $HOME/.kube/config \
|
||||||
|
--region <region> \
|
||||||
|
--token-version 2.0.0 \
|
||||||
|
--kube-endpoint PUBLIC_ENDPOINT
|
||||||
|
|
||||||
|
# 연결 확인
|
||||||
|
kubectl get nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
### OCIR(Oracle Container Image Registry) 로그인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# OCIR 로그인
|
||||||
|
# 사용자명 형식: <namespace>/oracleidentitycloudservice/<email>
|
||||||
|
# 비밀번호: OCI Console → User Settings → Auth Tokens에서 발급
|
||||||
|
|
||||||
|
docker login <region-code>.ocir.io
|
||||||
|
# 예) docker login icn.ocir.io
|
||||||
|
```
|
||||||
|
|
||||||
|
### Colima 시작 (ARM64 빌드용)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# OKE 노드가 ARM64인 경우 반드시 ARM64로 빌드해야 함
|
||||||
|
colima start --arch aarch64 --cpu 4 --memory 4
|
||||||
|
|
||||||
|
# ⚠️ Colima 시작 시 kubectl 컨텍스트가 'colima'로 바뀜
|
||||||
|
# OKE 컨텍스트로 복원 필수
|
||||||
|
kubectl config use-context <oke-context-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 인프라 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Internet (사용자) │
|
||||||
|
│ https://www.example.com │
|
||||||
|
└────────────────┬────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────▼────────┐
|
||||||
|
│ DNS Provider │ ← A 레코드 → NLB Public IP
|
||||||
|
└───────┬────────┘
|
||||||
|
│
|
||||||
|
┌────────────▼──────────────┐
|
||||||
|
│ OCI Network Load Balancer │ ← Nginx Ingress가 자동 생성
|
||||||
|
└────────────┬──────────────┘
|
||||||
|
│
|
||||||
|
┌────────────▼──────────────────────────────────┐
|
||||||
|
│ OKE Cluster │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────┐ │
|
||||||
|
│ │ Nginx Ingress Controller │ │
|
||||||
|
│ │ + cert-manager (Let's Encrypt)│ │
|
||||||
|
│ └───┬─────────────┬───────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ /api/* /* │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌───▼──────┐ ┌──▼───────┐ ┌──────────┐ │
|
||||||
|
│ │ Backend │ │ Frontend │ │ Redis │ │
|
||||||
|
│ │ :8000 │ │ :3001 │ │ :6379 │ │
|
||||||
|
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||||
|
└───────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────▼──────────────────────┐
|
||||||
|
│ External Services │
|
||||||
|
│ ├─ Oracle ADB (mTLS + Wallet) │
|
||||||
|
│ ├─ OCI GenAI │
|
||||||
|
│ └─ 기타 외부 API │
|
||||||
|
└────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 리소스 할당 예시 (ARM64 × 2노드, 2CPU/8GB 각)
|
||||||
|
|
||||||
|
| 컴포넌트 | CPU request/limit | Memory request/limit |
|
||||||
|
|----------|-------------------|----------------------|
|
||||||
|
| Backend | 500m / 1 | 768Mi / 1536Mi |
|
||||||
|
| Frontend | 200m / 500m | 256Mi / 512Mi |
|
||||||
|
| Redis | 100m / 200m | 128Mi / 256Mi |
|
||||||
|
| **합계** | **~800m** | **~1.2GB** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 최초 클러스터 설정 (1회성)
|
||||||
|
|
||||||
|
### 3.1 Nginx Ingress Controller 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
|
||||||
|
helm repo update
|
||||||
|
|
||||||
|
helm install ingress-nginx ingress-nginx/ingress-nginx \
|
||||||
|
--namespace ingress-nginx \
|
||||||
|
--create-namespace \
|
||||||
|
--set controller.service.type=LoadBalancer \
|
||||||
|
--set controller.service.annotations."oci\.oraclecloud\.com/load-balancer-type"="nlb" \
|
||||||
|
--set controller.service.externalTrafficPolicy=Local
|
||||||
|
|
||||||
|
# NLB 외부 IP 확인 (DNS에 연결할 IP)
|
||||||
|
kubectl get svc -n ingress-nginx ingress-nginx-controller
|
||||||
|
```
|
||||||
|
|
||||||
|
> **OKE 네트워크 설정**: VCN Security List에서 NodePort 범위(30000~32767)를 NLB IP 대역에서 허용해야 함.
|
||||||
|
|
||||||
|
### 3.2 cert-manager 설치 (Let's Encrypt 자동 인증서)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm repo add jetstack https://charts.jetstack.io
|
||||||
|
helm repo update
|
||||||
|
|
||||||
|
helm install cert-manager jetstack/cert-manager \
|
||||||
|
--namespace cert-manager \
|
||||||
|
--create-namespace \
|
||||||
|
--set crds.enabled=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 ClusterIssuer 생성
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# k8s/cert-manager/cluster-issuer.yaml
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: ClusterIssuer
|
||||||
|
metadata:
|
||||||
|
name: letsencrypt-prod
|
||||||
|
spec:
|
||||||
|
acme:
|
||||||
|
server: https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
email: your-email@example.com
|
||||||
|
privateKeySecretRef:
|
||||||
|
name: letsencrypt-prod
|
||||||
|
solvers:
|
||||||
|
- http01:
|
||||||
|
ingress:
|
||||||
|
class: nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f k8s/cert-manager/cluster-issuer.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 앱 네임스페이스 및 시크릿 생성
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 네임스페이스
|
||||||
|
kubectl apply -f k8s/namespace.yaml
|
||||||
|
|
||||||
|
# OCIR pull secret (이미지 다운로드용)
|
||||||
|
kubectl create secret docker-registry ocir-secret \
|
||||||
|
--namespace <app-namespace> \
|
||||||
|
--docker-server=<region>.ocir.io \
|
||||||
|
--docker-username='<namespace>/oracleidentitycloudservice/<email>' \
|
||||||
|
--docker-password='<auth-token>'
|
||||||
|
|
||||||
|
# Oracle Wallet (DB mTLS 인증)
|
||||||
|
kubectl create secret generic oracle-wallet \
|
||||||
|
--namespace <app-namespace> \
|
||||||
|
--from-file=cwallet.sso \
|
||||||
|
--from-file=ewallet.p12 \
|
||||||
|
--from-file=tnsnames.ora \
|
||||||
|
--from-file=sqlnet.ora \
|
||||||
|
--from-file=ojdbc.properties \
|
||||||
|
--from-file=keystore.jks \
|
||||||
|
--from-file=truststore.jks
|
||||||
|
|
||||||
|
# OCI 설정 (GenAI 등 OCI SDK 인증)
|
||||||
|
kubectl create secret generic oci-config \
|
||||||
|
--namespace <app-namespace> \
|
||||||
|
--from-file=config=~/.oci/config \
|
||||||
|
--from-file=oci_api_key.pem=~/.oci/oci_api_key.pem
|
||||||
|
|
||||||
|
# 앱 시크릿/설정
|
||||||
|
kubectl apply -f k8s/secrets.yaml
|
||||||
|
kubectl apply -f k8s/configmap.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 앱 배포 (최초)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f k8s/redis-deployment.yaml
|
||||||
|
kubectl apply -f k8s/backend-deployment.yaml
|
||||||
|
kubectl apply -f k8s/frontend-deployment.yaml
|
||||||
|
kubectl apply -f k8s/ingress.yaml
|
||||||
|
|
||||||
|
# 롤아웃 확인
|
||||||
|
kubectl rollout status deployment/backend -n <app-namespace>
|
||||||
|
kubectl rollout status deployment/frontend -n <app-namespace>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. K8s 매니페스트 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
k8s/
|
||||||
|
├── namespace.yaml # 네임스페이스 정의
|
||||||
|
├── configmap.yaml # 비민감 설정 (Redis 호스트, API 엔드포인트 등)
|
||||||
|
├── secrets.yaml # 민감 정보 (DB 비밀번호, API 키 등) ← .gitignore
|
||||||
|
├── secrets.yaml.template # 시크릿 템플릿 (Git에 포함)
|
||||||
|
├── backend-deployment.yaml # Backend Deployment + Service
|
||||||
|
├── frontend-deployment.yaml # Frontend Deployment + Service
|
||||||
|
├── redis-deployment.yaml # Redis Deployment + Service
|
||||||
|
├── ingress.yaml # Ingress (라우팅 + TLS)
|
||||||
|
└── cert-manager/
|
||||||
|
└── cluster-issuer.yaml # Let's Encrypt ClusterIssuer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Deployment 예시
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: backend
|
||||||
|
namespace: <app-namespace>
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: backend
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: backend
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: ocir-secret
|
||||||
|
containers:
|
||||||
|
- name: backend
|
||||||
|
image: <region>.ocir.io/<namespace>/<repo>/backend:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: 8000
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: <app>-config
|
||||||
|
- secretRef:
|
||||||
|
name: <app>-secrets
|
||||||
|
volumeMounts:
|
||||||
|
- name: oracle-wallet
|
||||||
|
mountPath: /etc/oracle/wallet
|
||||||
|
readOnly: true
|
||||||
|
- name: oci-config
|
||||||
|
mountPath: /root/.oci
|
||||||
|
readOnly: true
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: "500m"
|
||||||
|
memory: "768Mi"
|
||||||
|
limits:
|
||||||
|
cpu: "1"
|
||||||
|
memory: "1536Mi"
|
||||||
|
readinessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 30
|
||||||
|
volumes:
|
||||||
|
- name: oracle-wallet
|
||||||
|
secret:
|
||||||
|
secretName: oracle-wallet
|
||||||
|
- name: oci-config
|
||||||
|
secret:
|
||||||
|
secretName: oci-config
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: backend
|
||||||
|
namespace: <app-namespace>
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: backend
|
||||||
|
ports:
|
||||||
|
- port: 8000
|
||||||
|
targetPort: 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ingress 예시
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: <app>-ingress
|
||||||
|
namespace: <app-namespace>
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- www.example.com
|
||||||
|
secretName: <app>-tls
|
||||||
|
rules:
|
||||||
|
- host: www.example.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /api
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: backend
|
||||||
|
port:
|
||||||
|
number: 8000
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: frontend
|
||||||
|
port:
|
||||||
|
number: 3001
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Dockerfile 작성
|
||||||
|
|
||||||
|
### Backend (Spring Boot 멀티스테이지)
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# ── Build Stage ──
|
||||||
|
FROM eclipse-temurin:21-jdk AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 의존성 캐싱 (소스 변경 시에도 재사용)
|
||||||
|
COPY gradlew settings.gradle build.gradle ./
|
||||||
|
COPY gradle/ gradle/
|
||||||
|
RUN chmod +x gradlew && ./gradlew dependencies --no-daemon || true
|
||||||
|
|
||||||
|
# 소스 복사 & 빌드
|
||||||
|
COPY src/ src/
|
||||||
|
RUN ./gradlew bootJar -x test --no-daemon
|
||||||
|
|
||||||
|
# ── Runtime Stage ──
|
||||||
|
FROM eclipse-temurin:21-jre
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/build/libs/*.jar app.jar
|
||||||
|
EXPOSE 8000
|
||||||
|
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"
|
||||||
|
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**포인트:**
|
||||||
|
- Gradle 의존성을 먼저 복사/다운로드 → Docker 레이어 캐시 활용
|
||||||
|
- `-XX:MaxRAMPercentage=75.0` → 컨테이너 메모리 제한의 75%를 JVM 힙으로 사용
|
||||||
|
- 최종 이미지에 JDK 대신 JRE만 포함 → 이미지 크기 절감
|
||||||
|
|
||||||
|
### Frontend (Next.js standalone 멀티스테이지)
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# ── Build Stage ──
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 빌드 시점 환경변수 주입 (NEXT_PUBLIC_* 변수)
|
||||||
|
ARG NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
|
||||||
|
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Runtime Stage ──
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# standalone 출력 + 정적 파일 + public 복사
|
||||||
|
COPY --from=build /app/.next/standalone ./
|
||||||
|
COPY --from=build /app/.next/static ./.next/static
|
||||||
|
COPY --from=build /app/public ./public
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
ENV PORT=3001 HOSTNAME=0.0.0.0
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**포인트:**
|
||||||
|
- `next.config.ts`에 `output: "standalone"` 필수
|
||||||
|
- `NEXT_PUBLIC_*` 환경변수는 **빌드 시점**에만 주입 가능 (런타임 X)
|
||||||
|
- `.next/static`과 `public/`은 standalone에 포함되지 않으므로 수동 복사
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 배포 스크립트 (deploy.sh)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Configuration ──
|
||||||
|
REGISTRY="<region>.ocir.io/<namespace>/<repo>"
|
||||||
|
APP_NAMESPACE="<app-namespace>"
|
||||||
|
PLATFORM="linux/arm64" # OKE 노드 아키텍처에 맞춤
|
||||||
|
|
||||||
|
# ── Parse arguments ──
|
||||||
|
TARGET="all" # all | backend | frontend
|
||||||
|
MESSAGE=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--backend-only) TARGET="backend"; shift ;;
|
||||||
|
--frontend-only) TARGET="frontend"; shift ;;
|
||||||
|
--dry-run) echo "[DRY RUN]"; exit 0 ;;
|
||||||
|
-m) MESSAGE="$2"; shift 2 ;;
|
||||||
|
*) MESSAGE="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Auto-increment version ──
|
||||||
|
LATEST_TAG=$(git tag --sort=-v:refname | grep '^v' | head -1 2>/dev/null || echo "v0.1.0")
|
||||||
|
MAJOR=$(echo "$LATEST_TAG" | cut -d. -f1)
|
||||||
|
MINOR=$(echo "$LATEST_TAG" | cut -d. -f2)
|
||||||
|
PATCH=$(echo "$LATEST_TAG" | cut -d. -f3)
|
||||||
|
TAG="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||||
|
|
||||||
|
echo "━━━ Deploying ${TAG} (${TARGET}) ━━━"
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
# ── Build & Push ──
|
||||||
|
if [[ "$TARGET" == "all" || "$TARGET" == "backend" ]]; then
|
||||||
|
echo "▶ Building backend..."
|
||||||
|
docker build --platform "$PLATFORM" \
|
||||||
|
-t "$REGISTRY/backend:$TAG" \
|
||||||
|
-t "$REGISTRY/backend:latest" \
|
||||||
|
backend-java/
|
||||||
|
docker push "$REGISTRY/backend:$TAG"
|
||||||
|
docker push "$REGISTRY/backend:latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$TARGET" == "all" || "$TARGET" == "frontend" ]]; then
|
||||||
|
echo "▶ Building frontend..."
|
||||||
|
|
||||||
|
# .env.local에서 빌드 인자 읽기
|
||||||
|
MAPS_KEY=$(grep NEXT_PUBLIC_GOOGLE_MAPS_API_KEY frontend/.env.local 2>/dev/null | cut -d= -f2)
|
||||||
|
CLIENT_ID=$(grep NEXT_PUBLIC_GOOGLE_CLIENT_ID frontend/.env.local 2>/dev/null | cut -d= -f2)
|
||||||
|
|
||||||
|
docker build --platform "$PLATFORM" \
|
||||||
|
--build-arg NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="$MAPS_KEY" \
|
||||||
|
--build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID="$CLIENT_ID" \
|
||||||
|
-t "$REGISTRY/frontend:$TAG" \
|
||||||
|
-t "$REGISTRY/frontend:latest" \
|
||||||
|
frontend/
|
||||||
|
docker push "$REGISTRY/frontend:$TAG"
|
||||||
|
docker push "$REGISTRY/frontend:latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Rolling Update ──
|
||||||
|
# ⚠️ kubectl 컨텍스트가 OKE를 가리키는지 반드시 확인
|
||||||
|
if [[ "$TARGET" == "all" || "$TARGET" == "backend" ]]; then
|
||||||
|
kubectl set image deployment/backend \
|
||||||
|
backend="$REGISTRY/backend:$TAG" -n "$APP_NAMESPACE"
|
||||||
|
kubectl rollout status deployment/backend -n "$APP_NAMESPACE" --timeout=180s
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$TARGET" == "all" || "$TARGET" == "frontend" ]]; then
|
||||||
|
kubectl set image deployment/frontend \
|
||||||
|
frontend="$REGISTRY/frontend:$TAG" -n "$APP_NAMESPACE"
|
||||||
|
kubectl rollout status deployment/frontend -n "$APP_NAMESPACE" --timeout=120s
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Git Tag ──
|
||||||
|
git tag -a "$TAG" -m "Deploy ${TAG}: ${MESSAGE}"
|
||||||
|
git push origin "$TAG"
|
||||||
|
|
||||||
|
echo "━━━ ✅ Deploy complete: ${TAG} ━━━"
|
||||||
|
kubectl get pods -n "$APP_NAMESPACE"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 일상적인 배포 절차
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Colima 시작 (ARM64 Docker 빌드용, 꺼져있을 때만)
|
||||||
|
colima start --arch aarch64 --cpu 4 --memory 4
|
||||||
|
|
||||||
|
# 2. kubectl 컨텍스트를 OKE로 복원 (Colima가 바꿔버림)
|
||||||
|
kubectl config use-context <oke-context-name>
|
||||||
|
|
||||||
|
# 3. 배포 실행
|
||||||
|
./deploy.sh "변경 내용 설명" # 전체 배포
|
||||||
|
./deploy.sh --backend-only "API 수정" # 백엔드만
|
||||||
|
./deploy.sh --frontend-only "UI 수정" # 프론트엔드만
|
||||||
|
|
||||||
|
# 4. 확인
|
||||||
|
kubectl get pods -n <app-namespace>
|
||||||
|
kubectl logs -f deployment/backend -n <app-namespace>
|
||||||
|
|
||||||
|
# 5. (선택) Colima 중지 (리소스 절약)
|
||||||
|
colima stop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 시크릿/설정만 변경할 때
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# secrets.yaml 수정 후
|
||||||
|
kubectl apply -f k8s/secrets.yaml
|
||||||
|
|
||||||
|
# 시크릿 변경은 Pod를 자동 재시작하지 않음 → 수동 재시작 필요
|
||||||
|
kubectl rollout restart deployment/backend -n <app-namespace>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 롤백
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 이전 버전으로 롤백
|
||||||
|
kubectl rollout undo deployment/backend -n <app-namespace>
|
||||||
|
kubectl rollout undo deployment/frontend -n <app-namespace>
|
||||||
|
|
||||||
|
# 특정 리비전으로 롤백
|
||||||
|
kubectl rollout history deployment/backend -n <app-namespace>
|
||||||
|
kubectl rollout undo deployment/backend -n <app-namespace> --to-revision=3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 환경별 설정 관리
|
||||||
|
|
||||||
|
### 개발(Local) vs 운영(OKE) 비교
|
||||||
|
|
||||||
|
| 항목 | 개발 (Local) | 운영 (OKE) |
|
||||||
|
|------|-------------|-----------|
|
||||||
|
| DB 설정 | `backend/.env` | K8s Secret (`secrets.yaml`) |
|
||||||
|
| 환경변수 | 파일 기반 (`.env`, `.env.local`) | ConfigMap + Secret |
|
||||||
|
| Oracle Wallet | 로컬 디렉토리 | K8s Secret → Volume 마운트 |
|
||||||
|
| OCI 인증 | `~/.oci/config` | K8s Secret → Volume 마운트 |
|
||||||
|
| Redis | 로컬 IP (`192.168.x.x`) | K8s Service DNS (`redis`) |
|
||||||
|
| 프로세스 관리 | PM2 | K8s Deployment |
|
||||||
|
| 프론트엔드 모드 | `npm run dev` | `node server.js` (standalone) |
|
||||||
|
| DB 프로파일 | `_low` (리소스 절약) | `_medium` (적정 성능) |
|
||||||
|
|
||||||
|
### Oracle ADB 프로파일 선택
|
||||||
|
|
||||||
|
| 프로파일 | 병렬 처리 | 용도 |
|
||||||
|
|---------|----------|------|
|
||||||
|
| `_high` | 최대 | 대규모 배치/분석 |
|
||||||
|
| `_medium` | 중간 | **운영 권장** |
|
||||||
|
| `_low` | 최소 | **개발/테스트** (OCPU 절약) |
|
||||||
|
| `_tp` | 트랜잭션 | OLTP 워크로드 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 도메인 및 SSL 설정
|
||||||
|
|
||||||
|
### DNS 설정
|
||||||
|
|
||||||
|
```
|
||||||
|
# DNS Provider (예: Namecheap)에서 설정
|
||||||
|
Type Host Value TTL
|
||||||
|
A @ <NLB Public IP> 300
|
||||||
|
A www <NLB Public IP> 300
|
||||||
|
```
|
||||||
|
|
||||||
|
NLB Public IP 확인:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get svc -n ingress-nginx ingress-nginx-controller \
|
||||||
|
-o jsonpath='{.status.loadBalancer.ingress[0].ip}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSL 인증서
|
||||||
|
|
||||||
|
cert-manager + ClusterIssuer가 설정되어 있으면 Ingress에 annotation만 추가하면 자동 발급/갱신됨.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Ingress에 추가
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
spec:
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- www.example.com
|
||||||
|
secretName: <app>-tls # cert-manager가 자동 생성
|
||||||
|
```
|
||||||
|
|
||||||
|
인증서 상태 확인:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get certificate -n <app-namespace>
|
||||||
|
kubectl describe certificate <app>-tls -n <app-namespace>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 트러블슈팅
|
||||||
|
|
||||||
|
### 이미지 Pull 실패 (ImagePullBackOff)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl describe pod <pod-name> -n <app-namespace>
|
||||||
|
# Events 섹션에서 에러 확인
|
||||||
|
|
||||||
|
# 원인 1: OCIR 인증 실패 → pull secret 재생성
|
||||||
|
kubectl delete secret ocir-secret -n <app-namespace>
|
||||||
|
kubectl create secret docker-registry ocir-secret \
|
||||||
|
--namespace <app-namespace> \
|
||||||
|
--docker-server=<region>.ocir.io \
|
||||||
|
--docker-username='<namespace>/oracleidentitycloudservice/<email>' \
|
||||||
|
--docker-password='<auth-token>'
|
||||||
|
|
||||||
|
# 원인 2: 이미지가 존재하지 않음 → OCIR 콘솔에서 확인
|
||||||
|
```
|
||||||
|
|
||||||
|
### OKE CRI-O: short image name 오류
|
||||||
|
|
||||||
|
```
|
||||||
|
# ❌ 틀림
|
||||||
|
image: redis:7-alpine
|
||||||
|
|
||||||
|
# ✅ 맞음 (docker.io/library/ 접두사 필수)
|
||||||
|
image: docker.io/library/redis:7-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
OKE는 CRI-O 런타임을 사용하며, Docker Hub의 `library/` 이미지도 전체 경로를 명시해야 함.
|
||||||
|
|
||||||
|
### DB 연결 실패 (HikariPool timeout)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pod 로그 확인
|
||||||
|
kubectl logs deployment/backend -n <app-namespace> | grep -i "hikari\|oracle\|connection"
|
||||||
|
|
||||||
|
# 원인 1: Wallet이 마운트되지 않음
|
||||||
|
kubectl exec deployment/backend -n <app-namespace> -- ls /etc/oracle/wallet/
|
||||||
|
|
||||||
|
# 원인 2: ORACLE_DSN에 TNS_ADMIN 경로가 잘못됨
|
||||||
|
# 형식: <tns-alias>_medium?TNS_ADMIN=/etc/oracle/wallet
|
||||||
|
```
|
||||||
|
|
||||||
|
### Let's Encrypt 인증서 발급 실패
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Challenge 상태 확인
|
||||||
|
kubectl get challenges -n <app-namespace>
|
||||||
|
kubectl describe challenge <name> -n <app-namespace>
|
||||||
|
|
||||||
|
# 원인: VCN Security List에서 80번 포트가 막혀있음
|
||||||
|
# OCI Console → VCN → Security Lists → Ingress Rules에 HTTP 80 허용 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
### kubectl 인증 실패
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# kubeconfig 재생성
|
||||||
|
oci ce cluster create-kubeconfig \
|
||||||
|
--cluster-id <cluster-ocid> \
|
||||||
|
--file $HOME/.kube/config \
|
||||||
|
--region <region> \
|
||||||
|
--token-version 2.0.0 \
|
||||||
|
--kube-endpoint PUBLIC_ENDPOINT
|
||||||
|
|
||||||
|
# OCI CLI 프로파일 지정 (여러 프로파일 사용 시)
|
||||||
|
# kubeconfig의 args에 --profile <PROFILE_NAME> 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
### Colima 시작 후 kubectl 안 됨
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Colima가 kubectl 컨텍스트를 가져감
|
||||||
|
kubectl config get-contexts
|
||||||
|
kubectl config use-context <oke-context-name> # OKE로 복원
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 유용한 kubectl 명령어
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ── 상태 확인 ──
|
||||||
|
kubectl get pods -n <ns> # Pod 목록
|
||||||
|
kubectl get pods -n <ns> -w # 실시간 감시
|
||||||
|
kubectl get svc -n <ns> # Service 목록
|
||||||
|
kubectl get ingress -n <ns> # Ingress 확인
|
||||||
|
kubectl top pods -n <ns> # 리소스 사용량
|
||||||
|
|
||||||
|
# ── 로그 ──
|
||||||
|
kubectl logs deployment/backend -n <ns> # 최근 로그
|
||||||
|
kubectl logs deployment/backend -n <ns> -f # 실시간 로그
|
||||||
|
kubectl logs deployment/backend -n <ns> --previous # 이전 Pod 로그 (크래시 시)
|
||||||
|
|
||||||
|
# ── 디버깅 ──
|
||||||
|
kubectl describe pod <pod> -n <ns> # Pod 상세 (이벤트 포함)
|
||||||
|
kubectl exec -it deployment/backend -n <ns> -- sh # Pod 접속
|
||||||
|
kubectl port-forward svc/backend 8000:8000 -n <ns> # 로컬 포트 포워딩
|
||||||
|
|
||||||
|
# ── 배포 관리 ──
|
||||||
|
kubectl rollout status deployment/backend -n <ns> # 롤아웃 상태
|
||||||
|
kubectl rollout restart deployment/backend -n <ns> # 재시작 (설정 변경 반영)
|
||||||
|
kubectl rollout undo deployment/backend -n <ns> # 이전 버전 롤백
|
||||||
|
kubectl rollout history deployment/backend -n <ns> # 배포 이력
|
||||||
|
|
||||||
|
# ── 설정 변경 ──
|
||||||
|
kubectl apply -f k8s/configmap.yaml # ConfigMap 업데이트
|
||||||
|
kubectl apply -f k8s/secrets.yaml # Secret 업데이트
|
||||||
|
kubectl edit deployment backend -n <ns> # 직접 수정 (비추천)
|
||||||
|
|
||||||
|
# ── 정리 ──
|
||||||
|
kubectl delete pod <pod> -n <ns> # Pod 강제 재시작
|
||||||
|
kubectl scale deployment/backend --replicas=0 -n <ns> # 일시 중지
|
||||||
|
kubectl scale deployment/backend --replicas=1 -n <ns> # 복원
|
||||||
|
```
|
||||||
@@ -26,6 +26,7 @@ module.exports = {
|
|||||||
cwd: "/Users/joungmin/workspaces/tasteby/frontend",
|
cwd: "/Users/joungmin/workspaces/tasteby/frontend",
|
||||||
script: "npm",
|
script: "npm",
|
||||||
args: "run dev",
|
args: "run dev",
|
||||||
|
env: { PORT: 3001 },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ RUN npm ci
|
|||||||
COPY . .
|
COPY . .
|
||||||
ARG NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
|
ARG NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
|
||||||
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||||
|
ARG NEXT_PUBLIC_NAVER_MAP_CLIENT_ID
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ── Runtime stage ──
|
# ── Runtime stage ──
|
||||||
|
|||||||
36
frontend/__tests__/Stars.test.tsx
Normal file
36
frontend/__tests__/Stars.test.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* #343 — Stars 컴포넌트 렌더 테스트.
|
||||||
|
*/
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import Stars from "@/components/Stars";
|
||||||
|
|
||||||
|
describe("Stars", () => {
|
||||||
|
it("renders 5 star slots", () => {
|
||||||
|
const { container } = render(<Stars rating={3} />);
|
||||||
|
// 빈 별 5개 (text-gray-300 클래스 갖는 span)
|
||||||
|
const emptyStars = container.querySelectorAll("span.text-gray-300");
|
||||||
|
expect(emptyStars.length).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows aria-label with rating", () => {
|
||||||
|
render(<Stars rating={4.5} />);
|
||||||
|
expect(screen.getByLabelText("4.5점")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps rating to 0~5", () => {
|
||||||
|
render(<Stars rating={-1} />);
|
||||||
|
expect(screen.getByLabelText("0점")).toBeInTheDocument();
|
||||||
|
render(<Stars rating={10} />);
|
||||||
|
expect(screen.getByLabelText("5점")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows number when showNumber + rating > 0", () => {
|
||||||
|
const { container } = render(<Stars rating={3.5} showNumber />);
|
||||||
|
expect(container.textContent).toContain("3.5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show number when rating is 0 even with showNumber", () => {
|
||||||
|
const { container } = render(<Stars rating={0} showNumber />);
|
||||||
|
expect(container.textContent).not.toContain("0");
|
||||||
|
});
|
||||||
|
});
|
||||||
28
frontend/__tests__/admin-utils.test.ts
Normal file
28
frontend/__tests__/admin-utils.test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* #343 — admin-utils 순수 함수 단위 테스트.
|
||||||
|
*/
|
||||||
|
import { getAdminToken, authHeaders } from "@/lib/admin-utils";
|
||||||
|
|
||||||
|
describe("admin-utils", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getAdminToken returns null when not set", () => {
|
||||||
|
expect(getAdminToken()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getAdminToken returns stored token", () => {
|
||||||
|
localStorage.setItem("tasteby_token", "abc123");
|
||||||
|
expect(getAdminToken()).toBe("abc123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("authHeaders is empty when no token", () => {
|
||||||
|
expect(authHeaders()).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("authHeaders includes Bearer when token set", () => {
|
||||||
|
localStorage.setItem("tasteby_token", "xyz");
|
||||||
|
expect(authHeaders()).toEqual({ Authorization: "Bearer xyz" });
|
||||||
|
});
|
||||||
|
});
|
||||||
42
frontend/__tests__/i18n-config.test.ts
Normal file
42
frontend/__tests__/i18n-config.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* #343 — i18n/config 순수 함수 단위 테스트.
|
||||||
|
*/
|
||||||
|
import { isLocale, detectBrowserLocale, DEFAULT_LOCALE } from "@/i18n/config";
|
||||||
|
|
||||||
|
describe("i18n/config.isLocale", () => {
|
||||||
|
it("returns true for supported locales", () => {
|
||||||
|
expect(isLocale("ko")).toBe(true);
|
||||||
|
expect(isLocale("en")).toBe(true);
|
||||||
|
expect(isLocale("ja")).toBe(true);
|
||||||
|
expect(isLocale("es")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for unsupported / null / undefined", () => {
|
||||||
|
expect(isLocale("fr")).toBe(false);
|
||||||
|
expect(isLocale("zh")).toBe(false);
|
||||||
|
expect(isLocale(null)).toBe(false);
|
||||||
|
expect(isLocale(undefined)).toBe(false);
|
||||||
|
expect(isLocale("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("i18n/config.detectBrowserLocale", () => {
|
||||||
|
// jsdom의 navigator.language는 기본 'en-US'
|
||||||
|
it("returns supported locale from navigator.language", () => {
|
||||||
|
Object.defineProperty(navigator, "language", { value: "en-US", configurable: true });
|
||||||
|
expect(detectBrowserLocale()).toBe("en");
|
||||||
|
Object.defineProperty(navigator, "language", { value: "ko-KR", configurable: true });
|
||||||
|
expect(detectBrowserLocale()).toBe("ko");
|
||||||
|
Object.defineProperty(navigator, "language", { value: "ja", configurable: true });
|
||||||
|
expect(detectBrowserLocale()).toBe("ja");
|
||||||
|
Object.defineProperty(navigator, "language", { value: "es-MX", configurable: true });
|
||||||
|
expect(detectBrowserLocale()).toBe("es");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to DEFAULT_LOCALE for unsupported", () => {
|
||||||
|
Object.defineProperty(navigator, "language", { value: "fr-FR", configurable: true });
|
||||||
|
expect(detectBrowserLocale()).toBe(DEFAULT_LOCALE);
|
||||||
|
Object.defineProperty(navigator, "language", { value: "zh-CN", configurable: true });
|
||||||
|
expect(detectBrowserLocale()).toBe(DEFAULT_LOCALE);
|
||||||
|
});
|
||||||
|
});
|
||||||
341
frontend/docs/brand-guide.md
Normal file
341
frontend/docs/brand-guide.md
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
# Tasteby Brand & Design Guide
|
||||||
|
|
||||||
|
> Tasteby의 브랜드 아이덴티티, 디자인 시스템, 아이콘 정책을 정의하는 가이드 문서.
|
||||||
|
> 최종 수정: 2026-03-14
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 브랜드 개요
|
||||||
|
|
||||||
|
### 1.1 서비스 정의
|
||||||
|
**Tasteby**는 유튜브 맛집 영상에서 레스토랑 정보를 추출하여 지도 위에 큐레이션하는 서비스.
|
||||||
|
"유튜버의 입맛으로(Taste by)" 찾는 맛집이라는 의미를 담고 있다.
|
||||||
|
|
||||||
|
### 1.2 브랜드 키워드
|
||||||
|
- **미식 큐레이션** — 단순 검색이 아닌, 유튜버가 직접 방문한 맛집을 큐레이팅
|
||||||
|
- **신뢰** — 실제 영상 기반, 출처가 명확한 정보
|
||||||
|
- **따뜻함** — 음식과 사람을 연결하는 따뜻한 경험
|
||||||
|
- **프리미엄 캐주얼** — 고급스럽지만 접근하기 쉬운 톤
|
||||||
|
|
||||||
|
### 1.3 브랜드 보이스
|
||||||
|
| Do | Don't |
|
||||||
|
|----|-------|
|
||||||
|
| 친근하고 자연스러운 한국어 | 과도한 존댓말·마케팅 투 |
|
||||||
|
| 간결하고 핵심적인 정보 전달 | 불필요한 수식어 남발 |
|
||||||
|
| 음식에 대한 애정이 묻어나는 톤 | 딱딱한 기술 용어 노출 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 로고
|
||||||
|
|
||||||
|
### 2.1 로고 구성
|
||||||
|
Tasteby 로고는 **워드마크(Wordmark)** 형태로, 글자 "e" 안에 **위치 핀 마커**와 **스마일 곡선**이 결합되어 있다.
|
||||||
|
|
||||||
|
- 핀 마커 = 지도 기반 서비스
|
||||||
|
- 스마일 = 맛있는 음식의 만족감
|
||||||
|
- 색상: 본문 다크 그레이(`#3C3C3C`) + 핀/스마일 레드(`#E8534A`)
|
||||||
|
|
||||||
|
### 2.2 로고 파일
|
||||||
|
| 파일 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| `logo.png` | 원본 (고해상도) |
|
||||||
|
| `logo-200h.png` | 히어로/소개 영역 |
|
||||||
|
| `logo-120h.png` | 헤더/네비게이션 |
|
||||||
|
| `logo-80h.png` | 파비콘/소형 UI |
|
||||||
|
| `logo-dark.png` | 다크 배경용 (현재 미사용) |
|
||||||
|
| `logo-dark-120h.png` | 다크 배경 헤더용 (현재 미사용) |
|
||||||
|
| `logo-dark-80h.png` | 다크 배경 소형 (현재 미사용) |
|
||||||
|
|
||||||
|
### 2.3 로고 사용 규칙
|
||||||
|
- 최소 여백: 로고 높이의 50% 이상
|
||||||
|
- 배경: 크림 화이트(`#FFFAF5`) 또는 순백(`#FFFFFF`) 위에 사용
|
||||||
|
- 변형 금지: 회전, 기울임, 색상 임의 변경 불가
|
||||||
|
- 현재 라이트 모드 전용 — 다크 모드 미지원
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 컬러 시스템
|
||||||
|
|
||||||
|
### 3.1 디자인 테마: Saffron (사프란)
|
||||||
|
따뜻한 금빛 오렌지 톤. 고급스러운 미식 큐레이션 느낌을 전달한다.
|
||||||
|
|
||||||
|
### 3.2 브랜드 팔레트
|
||||||
|
CSS 변수로 `globals.css`의 `@theme inline`에 등록되어 있다.
|
||||||
|
|
||||||
|
| 토큰 | Hex | 용도 |
|
||||||
|
|------|-----|------|
|
||||||
|
| `brand-50` | `#FFF8F0` | 선택 상태 배경, 카드 hover |
|
||||||
|
| `brand-100` | `#FFEDD5` | 연한 배경, 뱃지 배경 |
|
||||||
|
| `brand-200` | `#FFD6A5` | 보조 배경 |
|
||||||
|
| `brand-300` | `#FFBC72` | 호버 보더, 보조 강조 |
|
||||||
|
| `brand-400` | `#F5A623` | 스피너, 중간 강조 |
|
||||||
|
| `brand-500` | `#F59E3F` | Primary 라이트 |
|
||||||
|
| `brand-600` | `#E8720C` | **Primary** — 아이콘, 버튼, 핵심 강조색 |
|
||||||
|
| `brand-700` | `#C45A00` | Primary 다크 — 텍스트 강조 |
|
||||||
|
| `brand-800` | `#9A4500` | 진한 강조 |
|
||||||
|
| `brand-900` | `#6B3000` | 다크 상태 |
|
||||||
|
| `brand-950` | `#3D1A00` | 최진한 강조 |
|
||||||
|
|
||||||
|
### 3.3 시맨틱 토큰
|
||||||
|
| 토큰 | Hex | 용도 |
|
||||||
|
|------|-----|------|
|
||||||
|
| `background` | `#FFFAF5` | 페이지 배경 (크림 화이트) |
|
||||||
|
| `foreground` | `#171717` | 본문 텍스트 |
|
||||||
|
| `surface` | `#FFFFFF` | 카드/패널 배경 |
|
||||||
|
|
||||||
|
### 3.4 컬러 사용 원칙
|
||||||
|
- 인터랙티브/강조 요소에는 반드시 `brand-*` 토큰 사용 (`orange-*` 금지)
|
||||||
|
- Tailwind 기본 `gray` 팔레트는 변경하지 않음 (다크 모드 호환 문제)
|
||||||
|
- 라이트 모드 전용 (`color-scheme: only light`)
|
||||||
|
- 다크 모드 CSS는 코멘트 처리하여 보존 중 (향후 지원 가능성)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 타이포그래피
|
||||||
|
|
||||||
|
### 4.1 서체 스택
|
||||||
|
```
|
||||||
|
Pretendard Variable → Geist → system-ui → sans-serif
|
||||||
|
```
|
||||||
|
|
||||||
|
| 서체 | 역할 | 로딩 방식 |
|
||||||
|
|------|------|----------|
|
||||||
|
| **Pretendard Variable** | 1순위 본문 서체 | 로컬 woff2 (`src/fonts/`) |
|
||||||
|
| **Geist** | 폴백 서체 | Google Fonts (`next/font/google`) |
|
||||||
|
|
||||||
|
### 4.2 선택 이유
|
||||||
|
- **Pretendard**: 한국어 가독성 최적화, 다양한 웨이트 지원, 깔끔하고 현대적
|
||||||
|
- **Geist**: 영문/숫자 렌더링 우수, Next.js 기본 서체로 호환성 확보
|
||||||
|
|
||||||
|
### 4.3 사용 원칙
|
||||||
|
- 웨이트: Regular(400), Medium(500), SemiBold(600), Bold(700) 주로 사용
|
||||||
|
- 한글 최소 본문 크기: 14px (가독성 확보)
|
||||||
|
- 레이블/캡션: 12px까지 허용
|
||||||
|
- HTML lang: `ko` (한국어 기본)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 아이콘 시스템
|
||||||
|
|
||||||
|
Tasteby는 **3개의 아이콘 레이어**를 사용한다.
|
||||||
|
|
||||||
|
### 5.1 Material Symbols Rounded — UI 아이콘
|
||||||
|
**용도**: 범용 UI (검색, 재생, 네비게이션, 상태 표시 등)
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| 폰트 | Material Symbols Rounded (Google Fonts) |
|
||||||
|
| 기본 설정 | FILL 0, wght 400, GRAD 0, opsz 24 |
|
||||||
|
| 컴포넌트 | `<Icon name="search" size={20} />` |
|
||||||
|
| 파일 | `src/components/Icon.tsx` |
|
||||||
|
|
||||||
|
**사용 위치**:
|
||||||
|
- RestaurantList 음식 카테고리 아이콘
|
||||||
|
- MapView 마커/InfoWindow 카테고리 아이콘
|
||||||
|
- SearchBar, 네비게이션, 재생 버튼 등 모든 UI 아이콘
|
||||||
|
- RestaurantDetail 상세 정보 아이콘
|
||||||
|
|
||||||
|
**주요 아이콘 매핑** (`getCuisineIcon()`):
|
||||||
|
- 한식 → `rice_bowl`, 일식 → `set_meal`, 중식 → `skillet`
|
||||||
|
- 양식 → `dinner_dining`, 아시아 → `restaurant`, 기타 → `flatware`
|
||||||
|
- 소분류별 세부 매핑 (67개 규칙) — `cuisine-icons.ts` 참조
|
||||||
|
|
||||||
|
**스타일 규칙**:
|
||||||
|
- 아이콘 크기와 `width`/`height`를 동일하게 설정하여 종횡비 고정
|
||||||
|
- `overflow: hidden`으로 비정형 아이콘(예: sake) 크기 넘침 방지
|
||||||
|
|
||||||
|
### 5.2 Phosphor Icons — 장르 카드 픽토그램
|
||||||
|
**용도**: 홈 탭 장르 필터 카드의 카테고리 아이콘
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| 패키지 | `@phosphor-icons/react` (^2.1.10) |
|
||||||
|
| 라이선스 | MIT |
|
||||||
|
| 스타일 | Regular (outline) 기본 |
|
||||||
|
| 파일 | `src/lib/cuisine-icons.ts` → `getPhosphorCuisineIcon()` |
|
||||||
|
|
||||||
|
**Phosphor 아이콘 매핑**:
|
||||||
|
|
||||||
|
| 카테고리 | 아이콘 | Phosphor 컴포넌트명 |
|
||||||
|
|----------|--------|---------------------|
|
||||||
|
| 한식 | 밥그릇 | `BowlFood` |
|
||||||
|
| 일식 | 물고기 | `FishSimple` |
|
||||||
|
| 중식 | 불꽃 | `Fire` |
|
||||||
|
| 양식 | 피자 | `Pizza` |
|
||||||
|
| 아시아 | 김 나는 그릇 | `BowlSteam` |
|
||||||
|
| 기타 | 쿠키 | `Cookie` |
|
||||||
|
|
||||||
|
**소분류 Phosphor 매핑** (주요 항목):
|
||||||
|
| 소분류 | 아이콘 | 비고 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| 국밥/해장국 | `BowlSteam` | |
|
||||||
|
| 소고기/한우구이 | `Cow` | |
|
||||||
|
| 곱창/막창 | `Flame` | |
|
||||||
|
| 닭/오리구이 | `Bird` | |
|
||||||
|
| 회/횟집 | `Fish` | |
|
||||||
|
| 해산물 | `Shrimp` | |
|
||||||
|
| 주점/포차 | `BeerStein` | |
|
||||||
|
| 이자카야 | `Martini` | 술잔 모양 |
|
||||||
|
| 파인다이닝/코스 | `Champagne` | |
|
||||||
|
| 스시/오마카세 | `Fish` | |
|
||||||
|
| 스테이크 | `Knife` | |
|
||||||
|
| 햄버거 | `Hamburger` | |
|
||||||
|
| 피자 | `Pizza` | |
|
||||||
|
| 프렌치 | `Champagne` | |
|
||||||
|
| 마라/훠궈 | `Pepper` | |
|
||||||
|
| 딤섬/만두 | `Egg` | |
|
||||||
|
| 비건/샐러드 | `Leaf` | |
|
||||||
|
| 카페/디저트 | `Coffee` | |
|
||||||
|
| 베이커리 | `Bread` | |
|
||||||
|
|
||||||
|
### 5.3 Custom SVG Food Icons — 커스텀 음식 픽토그램
|
||||||
|
**용도**: Phosphor에 적합한 아이콘이 없는 특수 음식 카테고리
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| 컴포넌트 | `<FoodIcon name="noodle" size={22} />` |
|
||||||
|
| 파일 | `src/components/FoodIcon.tsx` |
|
||||||
|
| 접두어 | `food:` (예: `food:noodle`) |
|
||||||
|
| 렌더링 | SVG inline, `fill` 또는 `stroke` 모드 |
|
||||||
|
|
||||||
|
**등록된 커스텀 아이콘**:
|
||||||
|
|
||||||
|
| 이름 | 대상 소분류 | viewBox | 렌더링 모드 | 출처 |
|
||||||
|
|------|------------|---------|------------|------|
|
||||||
|
| `jjigae` | 찌개/전골/탕 | 0 0 24 24 | stroke | 자체 제작 (뚝배기+김) |
|
||||||
|
| `tteok` | 분식 | 0 0 24 24 | stroke | 자체 제작 (가래떡) |
|
||||||
|
| `noodle` | 면, 라멘, 소바/우동, 베트남 | 0 0 363.674 363.674 | fill | 외부 SVG (그릇+면+젓가락) |
|
||||||
|
| `tempura` | 텐동/튀김 | 0 0 64 64 | fill | Flaticon (텐푸라) |
|
||||||
|
| `pig` | 삼겹살/돼지구이, 족발/보쌈, 돈카츠 | 0 0 90 90 | fill | 외부 SVG (돼지 전신) |
|
||||||
|
|
||||||
|
### 5.4 아이콘 선택 기준
|
||||||
|
|
||||||
|
```
|
||||||
|
1단계: Phosphor Icons에 적합한 아이콘이 있는가?
|
||||||
|
→ 있으면 Phosphor 사용
|
||||||
|
|
||||||
|
2단계: 없으면 커스텀 SVG(FoodIcon)로 제작/수급
|
||||||
|
→ food: 접두어로 매핑
|
||||||
|
|
||||||
|
3단계: UI 범용 아이콘은 Material Symbols
|
||||||
|
→ 검색, 네비, 상태 표시 등
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 아이콘 제작/추가 가이드
|
||||||
|
|
||||||
|
**커스텀 SVG 추가 시:**
|
||||||
|
1. `FoodIcon.tsx`의 `FOOD_ICONS` Record에 `IconDef` 추가
|
||||||
|
2. `viewBox`는 원본 SVG에 맞게 설정 (24×24가 아닐 수 있음)
|
||||||
|
3. `fill: true`면 fill 모드, 생략/false면 stroke 모드
|
||||||
|
4. `cuisine-icons.ts`의 `PHOSPHOR_SUB_RULES`에서 `food:이름`으로 매핑
|
||||||
|
5. SVG 경로는 JSX로 변환 (`clip-rule` → `clipRule` 등)
|
||||||
|
|
||||||
|
**라이선스 확인:**
|
||||||
|
- Phosphor Icons: MIT ✅
|
||||||
|
- Material Symbols: Apache 2.0 ✅
|
||||||
|
- 외부 SVG: 출처별 라이선스 확인 필수 (Flaticon은 Attribution 필요할 수 있음)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 레이아웃 & 컴포넌트 스타일
|
||||||
|
|
||||||
|
### 6.1 홈 탭 구성
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ [로고] [검색] [유튜버▼] │ ← 헤더
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ [한식][일식][중식][양식]... →scroll │ ← 장르 카드 (가로 스크롤+드래그)
|
||||||
|
│ [가격▼][지역▼][내위치][N개 결과] │ ← 필터 바 (flex-wrap)
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ 지도 / 리스트 / 근처 탭 │ ← 메인 콘텐츠
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 장르 카드 스타일
|
||||||
|
- 칩(chip) 형태, 가로 스크롤 + 마우스 드래그 지원 (`useDragScroll`)
|
||||||
|
- 아이콘(22px) + 텍스트 라벨
|
||||||
|
- 선택 시: `bg-brand-50 border-brand-300 text-brand-700`
|
||||||
|
- 비선택 시: `bg-white border-gray-200 text-gray-600`
|
||||||
|
- 아이콘 색상: 선택 시 `brand-600`, 비선택 시 `gray-400`
|
||||||
|
|
||||||
|
### 6.3 카드/서피스
|
||||||
|
- 배경: `surface` (#FFFFFF)
|
||||||
|
- 보더: `border-gray-200` (1px)
|
||||||
|
- 라운딩: `rounded-lg` (8px) 기본
|
||||||
|
- 그림자: 최소한 사용 (flat 디자인 지향)
|
||||||
|
|
||||||
|
### 6.4 버튼/인터랙티브
|
||||||
|
- Primary: `bg-brand-600 text-white hover:bg-brand-700`
|
||||||
|
- Secondary: `bg-brand-50 text-brand-700 border-brand-300`
|
||||||
|
- 포커스 링: `ring-brand-400`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 지도 마커 스타일
|
||||||
|
|
||||||
|
### 7.1 마커 디자인
|
||||||
|
텍스트 라벨 마커 (핀이 아닌 말풍선 형태):
|
||||||
|
- 배경: 흰색 (폐업 시 회색)
|
||||||
|
- 보더: 카테고리별 색상
|
||||||
|
- 텍스트: 레스토랑 이름 + 카테고리 아이콘 (Material Symbols)
|
||||||
|
- 하단 꼬리(arrow): 삼각형
|
||||||
|
- 선택 시: 파란색(`#1d4ed8`) 보더+꼬리
|
||||||
|
|
||||||
|
### 7.2 마커 카테고리 색상
|
||||||
|
| 카테고리 | 보더 | 배경 | 화살표 |
|
||||||
|
|----------|------|------|--------|
|
||||||
|
| 한식 | `#E8720C` | `#FFF8F0` | `#E8720C` |
|
||||||
|
| 일식 | `#D94F5A` | `#FFF5F5` | `#D94F5A` |
|
||||||
|
| 기타 | 기본 그레이 | 기본 화이트 | 기본 그레이 |
|
||||||
|
|
||||||
|
### 7.3 클러스터링
|
||||||
|
- 라이브러리: supercluster
|
||||||
|
- 클러스터 마커: 원형 + 개수 텍스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 향후 방향
|
||||||
|
|
||||||
|
### 8.1 다크 모드
|
||||||
|
- 현재 라이트 모드 전용
|
||||||
|
- 다크 모드 CSS 토큰은 정의되어 있으나 비활성 상태
|
||||||
|
- 다크 배경 로고 에셋 준비 완료 (`logo-dark-*.png`)
|
||||||
|
- 활성화 시 Tailwind 기본 gray 팔레트 유지 (커스텀 gray 금지)
|
||||||
|
|
||||||
|
### 8.2 아이콘 정리
|
||||||
|
- `@tabler/icons-react` 의존성 제거 가능 (현재 미사용)
|
||||||
|
- 커스텀 SVG 아이콘 추가 확장 가능 (소고기/해산물 등)
|
||||||
|
- Flaticon 에셋 사용 시 Attribution 라이선스 확인 필요
|
||||||
|
|
||||||
|
### 8.3 디자인 토큰 확장
|
||||||
|
- 현재: 색상 + 폰트 토큰만 정의
|
||||||
|
- 향후: spacing, radius, shadow, motion 토큰 추가 가능
|
||||||
|
- Tailwind v4 `@theme` 기반으로 확장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록: 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── docs/
|
||||||
|
│ ├── brand-guide.md ← 이 문서
|
||||||
|
│ └── design-concepts.md ← 초기 컨셉 후보안
|
||||||
|
├── public/
|
||||||
|
│ ├── logo.png ← 로고 에셋들
|
||||||
|
│ ├── logo-{80h,120h,200h}.png
|
||||||
|
│ ├── logo-dark.png
|
||||||
|
│ └── logo-dark-{80h,120h}.png
|
||||||
|
├── src/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── globals.css ← 디자인 토큰 (@theme inline)
|
||||||
|
│ │ ├── layout.tsx ← 폰트, 메타 설정
|
||||||
|
│ │ └── page.tsx ← 홈 탭 (장르 카드 렌더링)
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── Icon.tsx ← Material Symbols 래퍼
|
||||||
|
│ │ └── FoodIcon.tsx ← 커스텀 SVG 아이콘
|
||||||
|
│ ├── fonts/
|
||||||
|
│ │ └── PretendardVariable.woff2
|
||||||
|
│ └── lib/
|
||||||
|
│ └── cuisine-icons.ts ← 아이콘 매핑 (Material + Phosphor + Food)
|
||||||
|
```
|
||||||
21
frontend/jest.config.ts
Normal file
21
frontend/jest.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// #343 — Jest 설정. next/jest로 SWC 자동 통합.
|
||||||
|
|
||||||
|
import type { Config } from "jest";
|
||||||
|
import nextJest from "next/jest.js";
|
||||||
|
|
||||||
|
const createJestConfig = nextJest({
|
||||||
|
// 테스트 환경의 Next.js 앱 루트
|
||||||
|
dir: "./",
|
||||||
|
});
|
||||||
|
|
||||||
|
const customConfig: Config = {
|
||||||
|
// jest-dom matchers는 setupFilesAfterEnv로 등록 (Jest framework 로드 후)
|
||||||
|
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
|
||||||
|
testEnvironment: "jsdom",
|
||||||
|
moduleNameMapper: {
|
||||||
|
"^@/(.*)$": "<rootDir>/src/$1",
|
||||||
|
},
|
||||||
|
testPathIgnorePatterns: ["<rootDir>/.next/", "<rootDir>/node_modules/"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createJestConfig(customConfig);
|
||||||
2
frontend/jest.setup.ts
Normal file
2
frontend/jest.setup.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// #343 — Jest setup. @testing-library/jest-dom matchers 확장.
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
@@ -2,6 +2,14 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
// #343 — 외부 이미지 도메인 허용 (next/image)
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{ protocol: "https", hostname: "lh3.googleusercontent.com" }, // Google avatar
|
||||||
|
{ protocol: "https", hostname: "i.ytimg.com" }, // YouTube thumbnail
|
||||||
|
{ protocol: "https", hostname: "yt3.ggpht.com" }, // YouTube channel avatar
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
6407
frontend/package-lock.json
generated
6407
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,25 +6,35 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@react-oauth/google": "^0.13.4",
|
||||||
"@tabler/icons-react": "^3.40.0",
|
"@tabler/icons-react": "^3.40.0",
|
||||||
"@types/supercluster": "^7.1.3",
|
"@types/supercluster": "^7.1.3",
|
||||||
"@vis.gl/react-google-maps": "^1.7.1",
|
"@vis.gl/react-google-maps": "^1.7.1",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
"next-intl": "^4.13.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"supercluster": "^8.0.1"
|
"supercluster": "^8.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
|
"jest": "^30.4.2",
|
||||||
|
"jest-environment-jsdom": "^30.4.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
222
frontend/src/app/admin/_panels/ChannelsPanel.tsx
Normal file
222
frontend/src/app/admin/_panels/ChannelsPanel.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Channel } from "@/lib/api";
|
||||||
|
|
||||||
|
// #329 — admin/page.tsx에서 추출 (내부 로직 그대로)
|
||||||
|
export function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||||
|
const [channels, setChannels] = useState<Channel[]>([]);
|
||||||
|
const [newId, setNewId] = useState("");
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [newFilter, setNewFilter] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [scanResult, setScanResult] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
api.getChannels().then(setChannels).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!newId.trim() || !newName.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.addChannel(newId.trim(), newName.trim(), newFilter.trim() || undefined);
|
||||||
|
setNewId("");
|
||||||
|
setNewName("");
|
||||||
|
setNewFilter("");
|
||||||
|
load();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert(e instanceof Error ? e.message : "채널 추가 실패");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [editingChannel, setEditingChannel] = useState<string | null>(null);
|
||||||
|
const [editDesc, setEditDesc] = useState("");
|
||||||
|
const [editTags, setEditTags] = useState("");
|
||||||
|
const [editOrder, setEditOrder] = useState<number>(99);
|
||||||
|
|
||||||
|
const handleSaveChannel = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.updateChannel(id, { description: editDesc, tags: editTags, sort_order: editOrder });
|
||||||
|
setEditingChannel(null);
|
||||||
|
load();
|
||||||
|
} catch {
|
||||||
|
alert("채널 수정 실패");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (channelId: string, channelName: string) => {
|
||||||
|
if (!confirm(`"${channelName}" 채널을 삭제하시겠습니까?`)) return;
|
||||||
|
try {
|
||||||
|
await api.deleteChannel(channelId);
|
||||||
|
load();
|
||||||
|
} catch {
|
||||||
|
alert("채널 삭제 실패");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScan = async (channelId: string, full: boolean = false) => {
|
||||||
|
setScanResult((prev) => ({ ...prev, [channelId]: full ? "전체 스캔 중..." : "스캔 중..." }));
|
||||||
|
try {
|
||||||
|
const res = await api.scanChannel(channelId, full);
|
||||||
|
setScanResult((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[channelId]: `${res.total_fetched}개 조회, ${res.new_videos}개 신규${(res as Record<string, unknown>).filtered ? `, ${(res as Record<string, unknown>).filtered}개 필터링` : ""}`,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
setScanResult((prev) => ({ ...prev, [channelId]: "스캔 실패" }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{isAdmin && <div className="bg-surface rounded-lg shadow p-4 mb-6">
|
||||||
|
<h2 className="font-semibold mb-3">채널 추가</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
placeholder="YouTube Channel ID"
|
||||||
|
value={newId}
|
||||||
|
onChange={(e) => setNewId(e.target.value)}
|
||||||
|
className="border rounded px-3 py-2 flex-1 text-sm bg-surface text-gray-900"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="채널 이름"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
className="border rounded px-3 py-2 flex-1 text-sm bg-surface text-gray-900"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="제목 필터 (선택)"
|
||||||
|
value={newFilter}
|
||||||
|
onChange={(e) => setNewFilter(e.target.value)}
|
||||||
|
className="border rounded px-3 py-2 w-40 text-sm bg-surface text-gray-900"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-brand-600 text-white px-4 py-2 rounded text-sm hover:bg-brand-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
<div className="bg-surface rounded-lg shadow">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">채널 이름</th>
|
||||||
|
<th className="text-left px-4 py-3">Channel ID</th>
|
||||||
|
<th className="text-left px-4 py-3">제목 필터</th>
|
||||||
|
<th className="text-left px-4 py-3">설명</th>
|
||||||
|
<th className="text-left px-4 py-3">태그</th>
|
||||||
|
<th className="text-center px-4 py-3">순서</th>
|
||||||
|
<th className="text-right px-4 py-3">영상 수</th>
|
||||||
|
{isAdmin && <th className="text-left px-4 py-3">액션</th>}
|
||||||
|
<th className="text-left px-4 py-3">스캔 결과</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{channels.map((ch) => (
|
||||||
|
<tr key={ch.id} className="border-b hover:bg-brand-50/50">
|
||||||
|
<td className="px-4 py-3 font-medium">{ch.channel_name}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 font-mono text-xs">
|
||||||
|
{ch.channel_id}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm">
|
||||||
|
{ch.title_filter ? (
|
||||||
|
<span className="px-2 py-0.5 bg-brand-50 text-brand-700 rounded text-xs">
|
||||||
|
{ch.title_filter}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-xs">전체</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs">
|
||||||
|
{editingChannel === ch.id ? (
|
||||||
|
<input value={editDesc} onChange={(e) => setEditDesc(e.target.value)}
|
||||||
|
className="border rounded px-2 py-1 text-xs w-32 bg-surface text-gray-900" placeholder="설명" />
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-600 cursor-pointer" onClick={() => {
|
||||||
|
if (!isAdmin) return;
|
||||||
|
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || ""); setEditOrder(ch.sort_order ?? 99);
|
||||||
|
}}>{ch.description || <span className="text-gray-400">-</span>}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs">
|
||||||
|
{editingChannel === ch.id ? (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<input value={editTags} onChange={(e) => setEditTags(e.target.value)}
|
||||||
|
className="border rounded px-2 py-1 text-xs w-40 bg-surface text-gray-900" placeholder="태그 (쉼표 구분)" />
|
||||||
|
<button onClick={() => handleSaveChannel(ch.id)} className="text-brand-600 text-xs hover:underline">저장</button>
|
||||||
|
<button onClick={() => setEditingChannel(null)} className="text-gray-400 text-xs hover:underline">취소</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500 cursor-pointer" onClick={() => {
|
||||||
|
if (!isAdmin) return;
|
||||||
|
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || ""); setEditOrder(ch.sort_order ?? 99);
|
||||||
|
}}>{ch.tags ? ch.tags.split(",").map(t => t.trim()).join(", ") : <span className="text-gray-400">-</span>}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center text-xs">
|
||||||
|
{editingChannel === ch.id ? (
|
||||||
|
<input type="number" value={editOrder} onChange={(e) => setEditOrder(Number(e.target.value))}
|
||||||
|
className="border rounded px-2 py-1 text-xs w-14 text-center bg-surface text-gray-900" min={1} />
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">{ch.sort_order ?? 99}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-medium">
|
||||||
|
{ch.video_count > 0 ? (
|
||||||
|
<span className="px-2 py-0.5 bg-green-50 text-green-700 rounded text-xs">{ch.video_count}개</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-xs">0</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{isAdmin && <td className="px-4 py-3 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleScan(ch.channel_id)}
|
||||||
|
className="text-brand-600 hover:underline text-sm"
|
||||||
|
>
|
||||||
|
스캔
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleScan(ch.channel_id, true)}
|
||||||
|
className="text-purple-600 hover:underline text-sm"
|
||||||
|
>
|
||||||
|
전체 스캔
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(ch.id, ch.channel_name)}
|
||||||
|
className="text-red-500 hover:underline text-sm"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</td>}
|
||||||
|
<td className="px-4 py-3 text-gray-600">
|
||||||
|
{scanResult[ch.channel_id] || "-"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{channels.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||||
|
등록된 채널이 없습니다
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 영상 관리 ─── */
|
||||||
|
type VideoSortKey = "status" | "channel_name" | "title" | "published_at";
|
||||||
|
|
||||||
231
frontend/src/app/admin/_panels/DaemonPanel.tsx
Normal file
231
frontend/src/app/admin/_panels/DaemonPanel.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { DaemonConfig } from "@/lib/api";
|
||||||
|
|
||||||
|
// #329 — admin/page.tsx에서 추출
|
||||||
|
export function DaemonPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||||
|
const [config, setConfig] = useState<DaemonConfig | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [running, setRunning] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Editable fields
|
||||||
|
const [scanEnabled, setScanEnabled] = useState(false);
|
||||||
|
const [scanInterval, setScanInterval] = useState(60);
|
||||||
|
const [processEnabled, setProcessEnabled] = useState(false);
|
||||||
|
const [processInterval, setProcessInterval] = useState(60);
|
||||||
|
const [processLimit, setProcessLimit] = useState(10);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
api.getDaemonConfig().then((cfg) => {
|
||||||
|
setConfig(cfg);
|
||||||
|
setScanEnabled(cfg.scan_enabled);
|
||||||
|
setScanInterval(cfg.scan_interval_min);
|
||||||
|
setProcessEnabled(cfg.process_enabled);
|
||||||
|
setProcessInterval(cfg.process_interval_min);
|
||||||
|
setProcessLimit(cfg.process_limit);
|
||||||
|
}).catch(console.error).finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
await api.updateDaemonConfig({
|
||||||
|
scan_enabled: scanEnabled,
|
||||||
|
scan_interval_min: scanInterval,
|
||||||
|
process_enabled: processEnabled,
|
||||||
|
process_interval_min: processInterval,
|
||||||
|
process_limit: processLimit,
|
||||||
|
});
|
||||||
|
setResult("설정 저장 완료");
|
||||||
|
load();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setResult(e instanceof Error ? e.message : "저장 실패");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRunScan = async () => {
|
||||||
|
setRunning("scan");
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const res = await api.runDaemonScan();
|
||||||
|
setResult(`채널 스캔 완료: 신규 ${res.new_videos}개 영상`);
|
||||||
|
load();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setResult(e instanceof Error ? e.message : "스캔 실패");
|
||||||
|
} finally {
|
||||||
|
setRunning(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRunProcess = async () => {
|
||||||
|
setRunning("process");
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const res = await api.runDaemonProcess(processLimit);
|
||||||
|
setResult(`영상 처리 완료: ${res.restaurants_extracted}개 식당 추출`);
|
||||||
|
load();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setResult(e instanceof Error ? e.message : "처리 실패");
|
||||||
|
} finally {
|
||||||
|
setRunning(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <p className="text-gray-500">로딩 중...</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Schedule Config */}
|
||||||
|
<div className="bg-surface rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">스케줄 설정</h2>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
|
데몬이 실행 중일 때, 아래 설정에 따라 자동으로 채널 스캔 및 영상 처리를 수행합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Scan config */}
|
||||||
|
<div className="border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-medium">채널 스캔</h3>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={scanEnabled}
|
||||||
|
onChange={(e) => setScanEnabled(e.target.checked)}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className={`text-sm ${scanEnabled ? "text-green-600 font-medium" : "text-gray-500"}`}>
|
||||||
|
{scanEnabled ? "활성" : "비활성"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">주기 (분)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={scanInterval}
|
||||||
|
onChange={(e) => setScanInterval(Number(e.target.value))}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
min={1}
|
||||||
|
className="border rounded px-3 py-1.5 text-sm w-32"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{config?.last_scan_at && (
|
||||||
|
<p className="text-xs text-gray-400">마지막 스캔: {config.last_scan_at}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Process config */}
|
||||||
|
<div className="border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-medium">영상 처리</h3>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={processEnabled}
|
||||||
|
onChange={(e) => setProcessEnabled(e.target.checked)}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className={`text-sm ${processEnabled ? "text-green-600 font-medium" : "text-gray-500"}`}>
|
||||||
|
{processEnabled ? "활성" : "비활성"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">주기 (분)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={processInterval}
|
||||||
|
onChange={(e) => setProcessInterval(Number(e.target.value))}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
min={1}
|
||||||
|
className="border rounded px-3 py-1.5 text-sm w-32"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">처리 건수</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={processLimit}
|
||||||
|
onChange={(e) => setProcessLimit(Number(e.target.value))}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
className="border rounded px-3 py-1.5 text-sm w-32"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{config?.last_process_at && (
|
||||||
|
<p className="text-xs text-gray-400">마지막 처리: {config.last_process_at}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 bg-brand-600 text-white text-sm rounded hover:bg-brand-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "저장 중..." : "설정 저장"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual Triggers */}
|
||||||
|
<div className="bg-surface rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">수동 실행</h2>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
|
스케줄과 관계없이 즉시 실행합니다. 처리 시간이 걸릴 수 있습니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleRunScan}
|
||||||
|
disabled={running !== null}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white text-sm rounded hover:bg-green-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{running === "scan" ? "스캔 중..." : "채널 스캔 실행"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRunProcess}
|
||||||
|
disabled={running !== null}
|
||||||
|
className="px-4 py-2 bg-purple-600 text-white text-sm rounded hover:bg-purple-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{running === "process" ? "처리 중..." : "영상 처리 실행"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<p className={`mt-3 text-sm ${result.includes("실패") || result.includes("API") ? "text-red-600" : "text-green-600"}`}>
|
||||||
|
{result}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config updated_at */}
|
||||||
|
{config?.updated_at && (
|
||||||
|
<p className="text-xs text-gray-400 text-right">설정 수정일: {config.updated_at}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
654
frontend/src/app/admin/_panels/RestaurantsPanel.tsx
Normal file
654
frontend/src/app/admin/_panels/RestaurantsPanel.tsx
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
"use client";
|
||||||
|
import { getAdminToken, consumeSseStream } from "@/lib/admin-utils";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Restaurant, VideoLink } from "@/lib/api";
|
||||||
|
|
||||||
|
// #329 — admin/page.tsx에서 추출
|
||||||
|
export function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||||
|
const [restaurants, setRestaurants] = useState<Restaurant[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [nameSearch, setNameSearch] = useState("");
|
||||||
|
const [selected, setSelected] = useState<Restaurant | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState<Record<string, string>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [videos, setVideos] = useState<VideoLink[]>([]);
|
||||||
|
const [tablingSearching, setTablingSearching] = useState(false);
|
||||||
|
const [bulkTabling, setBulkTabling] = useState(false);
|
||||||
|
const [bulkTablingProgress, setBulkTablingProgress] = useState({ current: 0, total: 0, name: "", linked: 0, notFound: 0 });
|
||||||
|
const [catchtableSearching, setCatchtableSearching] = useState(false);
|
||||||
|
const [bulkCatchtable, setBulkCatchtable] = useState(false);
|
||||||
|
const [bulkCatchtableProgress, setBulkCatchtableProgress] = useState({ current: 0, total: 0, name: "", linked: 0, notFound: 0 });
|
||||||
|
type RestSortKey = "name" | "region" | "cuisine_type" | "price_range" | "rating" | "business_status";
|
||||||
|
const [sortKey, setSortKey] = useState<RestSortKey>("name");
|
||||||
|
const [sortAsc, setSortAsc] = useState(true);
|
||||||
|
const perPage = 20;
|
||||||
|
|
||||||
|
const handleSort = (key: RestSortKey) => {
|
||||||
|
if (sortKey === key) setSortAsc(!sortAsc);
|
||||||
|
else { setSortKey(key); setSortAsc(true); }
|
||||||
|
};
|
||||||
|
const sortIcon = (key: RestSortKey) => sortKey !== key ? " ↕" : sortAsc ? " ↑" : " ↓";
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
api
|
||||||
|
.getRestaurants({ limit: 500 })
|
||||||
|
.then(setRestaurants)
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
if (nameSearch && !r.name.toLowerCase().includes(nameSearch.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sorted = [...filtered].sort((a, b) => {
|
||||||
|
let av: string | number = a[sortKey] ?? "";
|
||||||
|
let bv: string | number = b[sortKey] ?? "";
|
||||||
|
if (sortKey === "rating") {
|
||||||
|
av = a.rating ?? 0;
|
||||||
|
bv = b.rating ?? 0;
|
||||||
|
}
|
||||||
|
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
|
||||||
|
return sortAsc ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(sorted.length / perPage));
|
||||||
|
const paged = sorted.slice(page * perPage, (page + 1) * perPage);
|
||||||
|
|
||||||
|
const handleSelect = (r: Restaurant) => {
|
||||||
|
if (selected?.id === r.id) {
|
||||||
|
setSelected(null);
|
||||||
|
setVideos([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelected(r);
|
||||||
|
api.getRestaurantVideos(r.id).then(setVideos).catch(() => setVideos([]));
|
||||||
|
setEditForm({
|
||||||
|
name: r.name || "",
|
||||||
|
address: r.address || "",
|
||||||
|
region: r.region || "",
|
||||||
|
cuisine_type: r.cuisine_type || "",
|
||||||
|
price_range: r.price_range || "",
|
||||||
|
phone: r.phone || "",
|
||||||
|
website: r.website || "",
|
||||||
|
latitude: r.latitude != null ? String(r.latitude) : "",
|
||||||
|
longitude: r.longitude != null ? String(r.longitude) : "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!selected) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const data: Record<string, unknown> = { ...editForm };
|
||||||
|
if (editForm.latitude) data.latitude = parseFloat(editForm.latitude);
|
||||||
|
else data.latitude = null;
|
||||||
|
if (editForm.longitude) data.longitude = parseFloat(editForm.longitude);
|
||||||
|
else data.longitude = null;
|
||||||
|
await api.updateRestaurant(selected.id, data as Partial<Restaurant>);
|
||||||
|
load();
|
||||||
|
setSelected(null);
|
||||||
|
} catch (e) {
|
||||||
|
alert("저장 실패: " + (e instanceof Error ? e.message : String(e)));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!selected) return;
|
||||||
|
if (!confirm(`"${selected.name}" 식당을 삭제하시겠습니까?\n연결된 영상 매핑, 리뷰, 벡터도 함께 삭제됩니다.`)) return;
|
||||||
|
try {
|
||||||
|
await api.deleteRestaurant(selected.id);
|
||||||
|
setSelected(null);
|
||||||
|
load();
|
||||||
|
} catch {
|
||||||
|
alert("삭제 실패");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="flex">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="식당 이름 검색..."
|
||||||
|
value={nameSearch}
|
||||||
|
onChange={(e) => { setNameSearch(e.target.value); setPage(0); }}
|
||||||
|
onKeyDown={(e) => e.key === "Escape" && setNameSearch("")}
|
||||||
|
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-surface text-gray-900"
|
||||||
|
/>
|
||||||
|
{nameSearch ? (
|
||||||
|
<button
|
||||||
|
onClick={() => { setNameSearch(""); setPage(0); }}
|
||||||
|
className="border rounded-r px-2 py-2 text-sm text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => load()}
|
||||||
|
className="border rounded-r px-2 py-2 text-sm text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
||||||
|
title="새로고침"
|
||||||
|
>
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{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
|
||||||
|
onClick={async () => {
|
||||||
|
const pending = await fetch(`/api/restaurants/tabling-pending`, {
|
||||||
|
headers: { Authorization: `Bearer ${getAdminToken()}` },
|
||||||
|
}).then(r => r.json());
|
||||||
|
if (pending.count === 0) { alert("테이블링 미연결 식당이 없습니다"); return; }
|
||||||
|
if (!confirm(`테이블링 미연결 식당 ${pending.count}개를 벌크 검색합니다.\n식당당 5~15초 소요됩니다. 진행할까요?`)) return;
|
||||||
|
setBulkTabling(true);
|
||||||
|
setBulkTablingProgress({ current: 0, total: pending.count, name: "", linked: 0, notFound: 0 });
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/restaurants/bulk-tabling", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${getAdminToken()}` },
|
||||||
|
});
|
||||||
|
// #351 — consumeSseStream으로 통일
|
||||||
|
await consumeSseStream(res, (raw) => {
|
||||||
|
const evt = raw as { type: string; [k: string]: unknown };
|
||||||
|
if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
|
||||||
|
setBulkTablingProgress(p => ({
|
||||||
|
...p, current: evt.current as number, total: (evt.total as number) || p.total, name: evt.name as string,
|
||||||
|
linked: evt.type === "done" ? p.linked + 1 : p.linked,
|
||||||
|
notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
|
||||||
|
}));
|
||||||
|
} else if (evt.type === "complete") {
|
||||||
|
alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}개`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) { alert("벌크 테이블링 실패: " + (e instanceof Error ? e.message : String(e))); }
|
||||||
|
finally { setBulkTabling(false); load(); }
|
||||||
|
}}
|
||||||
|
disabled={bulkTabling}
|
||||||
|
className="px-3 py-1.5 text-xs bg-brand-500 text-white rounded hover:bg-brand-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{bulkTabling ? `테이블링 검색 중 (${bulkTablingProgress.current}/${bulkTablingProgress.total})` : "벌크 테이블링 연결"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm("테이블링 매핑을 전부 초기화하시겠습니까?")) return;
|
||||||
|
try {
|
||||||
|
await fetch("/api/restaurants/reset-tabling", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${getAdminToken()}` },
|
||||||
|
});
|
||||||
|
alert("테이블링 매핑 초기화 완료");
|
||||||
|
load();
|
||||||
|
} catch (e) { alert("실패: " + e); }
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 text-xs bg-red-50 text-red-600 border border-red-200 rounded hover:bg-red-100"
|
||||||
|
>
|
||||||
|
테이블링 초기화
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm("캐치테이블 매핑을 전부 초기화하시겠습니까?")) return;
|
||||||
|
try {
|
||||||
|
await fetch("/api/restaurants/reset-catchtable", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${getAdminToken()}` },
|
||||||
|
});
|
||||||
|
alert("캐치테이블 매핑 초기화 완료");
|
||||||
|
load();
|
||||||
|
} catch (e) { alert("실패: " + e); }
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 text-xs bg-red-50 text-red-600 border border-red-200 rounded hover:bg-red-100"
|
||||||
|
>
|
||||||
|
캐치테이블 초기화
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const pending = await fetch(`/api/restaurants/catchtable-pending`, {
|
||||||
|
headers: { Authorization: `Bearer ${getAdminToken()}` },
|
||||||
|
}).then(r => r.json());
|
||||||
|
if (pending.count === 0) { alert("캐치테이블 미연결 식당이 없습니다"); return; }
|
||||||
|
if (!confirm(`캐치테이블 미연결 식당 ${pending.count}개를 벌크 검색합니다.\n식당당 5~15초 소요됩니다. 진행할까요?`)) return;
|
||||||
|
setBulkCatchtable(true);
|
||||||
|
setBulkCatchtableProgress({ current: 0, total: pending.count, name: "", linked: 0, notFound: 0 });
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/restaurants/bulk-catchtable", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${getAdminToken()}` },
|
||||||
|
});
|
||||||
|
// #351 — consumeSseStream으로 통일
|
||||||
|
await consumeSseStream(res, (raw) => {
|
||||||
|
const evt = raw as { type: string; [k: string]: unknown };
|
||||||
|
if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
|
||||||
|
setBulkCatchtableProgress(p => ({
|
||||||
|
...p, current: evt.current as number, total: (evt.total as number) || p.total, name: evt.name as string,
|
||||||
|
linked: evt.type === "done" ? p.linked + 1 : p.linked,
|
||||||
|
notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
|
||||||
|
}));
|
||||||
|
} else if (evt.type === "complete") {
|
||||||
|
alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}개`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) { alert("벌크 캐치테이블 실패: " + (e instanceof Error ? e.message : String(e))); }
|
||||||
|
finally { setBulkCatchtable(false); load(); }
|
||||||
|
}}
|
||||||
|
disabled={bulkCatchtable}
|
||||||
|
className="px-3 py-1.5 text-xs bg-violet-500 text-white rounded hover:bg-violet-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{bulkCatchtable ? `캐치테이블 검색 중 (${bulkCatchtableProgress.current}/${bulkCatchtableProgress.total})` : "벌크 캐치테이블 연결"}
|
||||||
|
</button>
|
||||||
|
</>)}
|
||||||
|
<span className="text-sm text-gray-400 ml-auto">
|
||||||
|
{nameSearch ? `${sorted.length} / ` : ""}총 {restaurants.length}개 식당
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{bulkTabling && bulkTablingProgress.name && (
|
||||||
|
<div className="bg-brand-50 rounded p-3 mb-4 text-sm">
|
||||||
|
<div className="flex justify-between mb-1">
|
||||||
|
<span>{bulkTablingProgress.current}/{bulkTablingProgress.total} - {bulkTablingProgress.name}</span>
|
||||||
|
<span className="text-xs text-gray-500">연결: {bulkTablingProgress.linked} / 미발견: {bulkTablingProgress.notFound}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-brand-200 rounded-full h-1.5">
|
||||||
|
<div className="bg-brand-500 h-1.5 rounded-full transition-all" style={{ width: `${(bulkTablingProgress.current / bulkTablingProgress.total) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bulkCatchtable && bulkCatchtableProgress.name && (
|
||||||
|
<div className="bg-violet-50 rounded p-3 mb-4 text-sm">
|
||||||
|
<div className="flex justify-between mb-1">
|
||||||
|
<span>{bulkCatchtableProgress.current}/{bulkCatchtableProgress.total} - {bulkCatchtableProgress.name}</span>
|
||||||
|
<span className="text-xs text-gray-500">연결: {bulkCatchtableProgress.linked} / 미발견: {bulkCatchtableProgress.notFound}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-violet-200 rounded-full h-1.5">
|
||||||
|
<div className="bg-violet-500 h-1.5 rounded-full transition-all" style={{ width: `${(bulkCatchtableProgress.current / bulkCatchtableProgress.total) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-surface rounded-lg shadow overflow-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("name")}>이름{sortIcon("name")}</th>
|
||||||
|
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("region")}>지역{sortIcon("region")}</th>
|
||||||
|
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("cuisine_type")}>음식 종류{sortIcon("cuisine_type")}</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("business_status")}>상태{sortIcon("business_status")}</th>
|
||||||
|
<th className="text-center px-4 py-3">검증</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paged.map((r) => (
|
||||||
|
<tr
|
||||||
|
key={r.id}
|
||||||
|
onClick={() => handleSelect(r)}
|
||||||
|
className={`border-b cursor-pointer hover:bg-brand-50/50 ${selected?.id === r.id ? "bg-brand-50" : ""}`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-medium">{r.name}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600 text-xs">{r.region || "-"}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600 text-xs">{r.cuisine_type || "-"}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600 text-xs">{r.price_range || "-"}</td>
|
||||||
|
<td className="px-4 py-3 text-center text-xs">
|
||||||
|
{r.rating ? (
|
||||||
|
<span><span className="text-yellow-500">★</span> {r.rating}</span>
|
||||||
|
) : "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{r.business_status === "CLOSED_PERMANENTLY" ? (
|
||||||
|
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold">폐업</span>
|
||||||
|
) : r.business_status === "CLOSED_TEMPORARILY" ? (
|
||||||
|
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded text-[10px] font-semibold">임시휴업</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">-</span>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
))}
|
||||||
|
{!loading && filtered.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||||
|
식당 데이터가 없습니다
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-4">
|
||||||
|
<button onClick={() => setPage(0)} disabled={page === 0} className="px-3 py-1 rounded border text-sm disabled:opacity-30 hover:bg-gray-100">«</button>
|
||||||
|
<button onClick={() => setPage(page - 1)} disabled={page === 0} className="px-3 py-1 rounded border text-sm disabled:opacity-30 hover:bg-gray-100">‹</button>
|
||||||
|
<span className="text-sm text-gray-600 px-2">{page + 1} / {totalPages}</span>
|
||||||
|
<button onClick={() => setPage(page + 1)} disabled={page >= totalPages - 1} className="px-3 py-1 rounded border text-sm disabled:opacity-30 hover:bg-gray-100">›</button>
|
||||||
|
<button onClick={() => setPage(totalPages - 1)} disabled={page >= totalPages - 1} className="px-3 py-1 rounded border text-sm disabled:opacity-30 hover:bg-gray-100">»</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 식당 상세/수정 패널 */}
|
||||||
|
{selected && (
|
||||||
|
<div className="mt-6 bg-surface rounded-lg shadow p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold text-base">{selected.name}</h3>
|
||||||
|
<button onClick={() => setSelected(null)} className="text-gray-400 hover:text-gray-600 text-xl leading-none">x</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{[
|
||||||
|
{ key: "name", label: "이름" },
|
||||||
|
{ key: "address", label: "주소" },
|
||||||
|
{ key: "region", label: "지역" },
|
||||||
|
{ key: "cuisine_type", label: "음식 종류" },
|
||||||
|
{ key: "price_range", label: "가격대" },
|
||||||
|
{ key: "phone", label: "전화" },
|
||||||
|
{ key: "website", label: "웹사이트" },
|
||||||
|
{ key: "latitude", label: "위도" },
|
||||||
|
{ key: "longitude", label: "경도" },
|
||||||
|
].map(({ key, label }) => (
|
||||||
|
<div key={key}>
|
||||||
|
<label className="text-xs text-gray-500">{label}</label>
|
||||||
|
<input
|
||||||
|
value={editForm[key] || ""}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, [key]: e.target.value }))}
|
||||||
|
className="w-full border rounded px-2 py-1.5 text-sm bg-surface text-gray-900"
|
||||||
|
disabled={!isAdmin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{selected.business_status && (
|
||||||
|
<p className="mt-3 text-xs text-gray-500">
|
||||||
|
Google 상태: <span className="font-medium">{selected.business_status}</span>
|
||||||
|
{selected.rating && <> / ★ {selected.rating} ({selected.rating_count?.toLocaleString()})</>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{selected.google_place_id && (
|
||||||
|
<p className="mt-1">
|
||||||
|
<a
|
||||||
|
href={`https://www.google.com/maps/place/?q=place_id:${selected.google_place_id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-brand-600 hover:underline text-xs"
|
||||||
|
>
|
||||||
|
Google Maps에서 보기
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{/* 테이블링 연결 */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="mt-4 border-t pt-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h4 className="text-xs font-semibold text-gray-500">테이블링</h4>
|
||||||
|
{selected.tabling_url === "NONE" ? (
|
||||||
|
<span className="text-xs text-gray-400">검색완료-없음</span>
|
||||||
|
) : selected.tabling_url ? (
|
||||||
|
<a href={selected.tabling_url} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="text-brand-600 hover:underline text-xs">{selected.tabling_url}</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">미연결</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setTablingSearching(true);
|
||||||
|
try {
|
||||||
|
const results = await api.searchTabling(selected.id);
|
||||||
|
if (results.length === 0) {
|
||||||
|
alert("테이블링에서 검색 결과가 없습니다");
|
||||||
|
} else {
|
||||||
|
const best = results[0];
|
||||||
|
if (confirm(`"${best.title}"\n${best.url}\n\n이 테이블링 페이지를 연결할까요?`)) {
|
||||||
|
await api.setTablingUrl(selected.id, best.url);
|
||||||
|
setSelected({ ...selected, tabling_url: best.url });
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { alert("검색 실패: " + (e instanceof Error ? e.message : String(e))); }
|
||||||
|
finally { setTablingSearching(false); }
|
||||||
|
}}
|
||||||
|
disabled={tablingSearching}
|
||||||
|
className="px-2 py-0.5 text-[11px] bg-brand-500 text-white rounded hover:bg-brand-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{tablingSearching ? "검색 중..." : "테이블링 검색"}
|
||||||
|
</button>
|
||||||
|
{selected.tabling_url && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await api.setTablingUrl(selected.id, "");
|
||||||
|
setSelected({ ...selected, tabling_url: null });
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
className="px-2 py-0.5 text-[11px] text-red-500 border border-red-200 rounded hover:bg-red-50"
|
||||||
|
>
|
||||||
|
연결 해제
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 캐치테이블 연결 */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="mt-4 border-t pt-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h4 className="text-xs font-semibold text-gray-500">캐치테이블</h4>
|
||||||
|
{selected.catchtable_url === "NONE" ? (
|
||||||
|
<span className="text-xs text-gray-400">검색완료-없음</span>
|
||||||
|
) : selected.catchtable_url ? (
|
||||||
|
<a href={selected.catchtable_url} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="text-brand-600 hover:underline text-xs">{selected.catchtable_url}</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">미연결</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setCatchtableSearching(true);
|
||||||
|
try {
|
||||||
|
const results = await api.searchCatchtable(selected.id);
|
||||||
|
if (results.length === 0) {
|
||||||
|
alert("캐치테이블에서 검색 결과가 없습니다");
|
||||||
|
} else {
|
||||||
|
const best = results[0];
|
||||||
|
if (confirm(`"${best.title}"\n${best.url}\n\n이 캐치테이블 페이지를 연결할까요?`)) {
|
||||||
|
await api.setCatchtableUrl(selected.id, best.url);
|
||||||
|
setSelected({ ...selected, catchtable_url: best.url });
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { alert("검색 실패: " + (e instanceof Error ? e.message : String(e))); }
|
||||||
|
finally { setCatchtableSearching(false); }
|
||||||
|
}}
|
||||||
|
disabled={catchtableSearching}
|
||||||
|
className="px-2 py-0.5 text-[11px] bg-violet-500 text-white rounded hover:bg-violet-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{catchtableSearching ? "검색 중..." : "캐치테이블 검색"}
|
||||||
|
</button>
|
||||||
|
{selected.catchtable_url && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await api.setCatchtableUrl(selected.id, "");
|
||||||
|
setSelected({ ...selected, catchtable_url: null });
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
className="px-2 py-0.5 text-[11px] text-red-500 border border-red-200 rounded hover:bg-red-50"
|
||||||
|
>
|
||||||
|
연결 해제
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{videos.length > 0 && (
|
||||||
|
<div className="mt-4 border-t pt-3">
|
||||||
|
<h4 className="text-xs font-semibold text-gray-500 mb-2">연결된 영상 ({videos.length})</h4>
|
||||||
|
<div className="space-y-1.5 max-h-32 overflow-y-auto">
|
||||||
|
{videos.map((v) => (
|
||||||
|
<div key={v.video_id} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="px-1.5 py-0.5 bg-red-50 text-red-600 rounded text-[10px] font-medium shrink-0">
|
||||||
|
{v.channel_name}
|
||||||
|
</span>
|
||||||
|
<a href={v.url} target="_blank" rel="noopener noreferrer" className="text-brand-600 hover:underline truncate">
|
||||||
|
{v.title}
|
||||||
|
</a>
|
||||||
|
<span className="text-gray-400 shrink-0">{v.published_at?.slice(0, 10)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
{isAdmin && <button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "저장 중..." : "저장"}
|
||||||
|
</button>}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelected(null)}
|
||||||
|
className="px-4 py-2 text-sm border rounded text-gray-600 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
{isAdmin ? "취소" : "닫기"}
|
||||||
|
</button>
|
||||||
|
{isAdmin && <button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="px-4 py-2 text-sm text-red-500 border border-red-200 rounded hover:bg-red-50 ml-auto"
|
||||||
|
>
|
||||||
|
식당 삭제
|
||||||
|
</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ─── 유저 관리 ─── */
|
||||||
|
interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
email: string | null;
|
||||||
|
nickname: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
is_admin: boolean;
|
||||||
|
provider: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
favorite_count: number;
|
||||||
|
review_count: number;
|
||||||
|
memo_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserFavorite {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string | null;
|
||||||
|
region: string | null;
|
||||||
|
cuisine_type: string | null;
|
||||||
|
rating: number | null;
|
||||||
|
business_status: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserReview {
|
||||||
|
id: string;
|
||||||
|
restaurant_id: string;
|
||||||
|
rating: number;
|
||||||
|
review_text: string | null;
|
||||||
|
visited_at: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
restaurant_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserMemo {
|
||||||
|
id: string;
|
||||||
|
restaurant_id: string;
|
||||||
|
rating: number | null;
|
||||||
|
memo_text: string | null;
|
||||||
|
visited_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
restaurant_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
383
frontend/src/app/admin/_panels/UsersPanel.tsx
Normal file
383
frontend/src/app/admin/_panels/UsersPanel.tsx
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
// #329 — admin/page.tsx에서 추출
|
||||||
|
interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
email: string | null;
|
||||||
|
nickname: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
is_admin: boolean;
|
||||||
|
provider: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
favorite_count: number;
|
||||||
|
review_count: number;
|
||||||
|
memo_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserFavorite {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string | null;
|
||||||
|
region: string | null;
|
||||||
|
cuisine_type: string | null;
|
||||||
|
rating: number | null;
|
||||||
|
business_status: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserReview {
|
||||||
|
id: string;
|
||||||
|
restaurant_id: string;
|
||||||
|
rating: number;
|
||||||
|
review_text: string | null;
|
||||||
|
visited_at: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
restaurant_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserMemo {
|
||||||
|
id: string;
|
||||||
|
restaurant_id: string;
|
||||||
|
rating: number | null;
|
||||||
|
memo_text: string | null;
|
||||||
|
visited_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
restaurant_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersPanel() {
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
|
||||||
|
const [favorites, setFavorites] = useState<UserFavorite[]>([]);
|
||||||
|
const [reviews, setReviews] = useState<UserReview[]>([]);
|
||||||
|
const [memos, setMemos] = useState<UserMemo[]>([]);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const perPage = 20;
|
||||||
|
|
||||||
|
const loadUsers = useCallback(async (p: number) => {
|
||||||
|
try {
|
||||||
|
const res = await api.getAdminUsers({ limit: perPage, offset: p * perPage });
|
||||||
|
setUsers(res.users);
|
||||||
|
setTotal(res.total);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load users:", e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers(page);
|
||||||
|
}, [page, loadUsers]);
|
||||||
|
|
||||||
|
const handleSelectUser = async (u: AdminUser) => {
|
||||||
|
if (selectedUser?.id === u.id) {
|
||||||
|
setSelectedUser(null);
|
||||||
|
setFavorites([]);
|
||||||
|
setReviews([]);
|
||||||
|
setMemos([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedUser(u);
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const [favs, revs, mems] = await Promise.all([
|
||||||
|
api.getAdminUserFavorites(u.id),
|
||||||
|
api.getAdminUserReviews(u.id),
|
||||||
|
api.getAdminUserMemos(u.id),
|
||||||
|
]);
|
||||||
|
setFavorites(favs);
|
||||||
|
setReviews(revs);
|
||||||
|
setMemos(mems);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / perPage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-bold">유저 관리 ({total}명)</h2>
|
||||||
|
|
||||||
|
{/* Users Table */}
|
||||||
|
<div className="bg-surface rounded-lg shadow overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
|
||||||
|
<tr>
|
||||||
|
<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-left px-4 py-2">가입일</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr
|
||||||
|
key={u.id}
|
||||||
|
onClick={() => handleSelectUser(u)}
|
||||||
|
className={`border-t cursor-pointer transition-colors ${
|
||||||
|
selectedUser?.id === u.id
|
||||||
|
? "bg-brand-50"
|
||||||
|
: "hover:bg-brand-50/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{u.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={u.avatar_url}
|
||||||
|
alt=""
|
||||||
|
className="w-7 h-7 rounded-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-7 h-7 rounded-full bg-gray-200 flex items-center justify-center text-xs font-semibold text-gray-500">
|
||||||
|
{(u.nickname || u.email || "?").charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="font-medium">
|
||||||
|
{u.nickname || "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
{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">
|
||||||
|
{u.favorite_count}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-300">0</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
{u.review_count > 0 ? (
|
||||||
|
<span className="inline-block px-2 py-0.5 bg-brand-50 text-brand-600 rounded-full text-xs font-medium">
|
||||||
|
{u.review_count}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-300">0</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
{u.memo_count > 0 ? (
|
||||||
|
<span className="inline-block px-2 py-0.5 bg-purple-50 text-purple-600 rounded-full text-xs font-medium">
|
||||||
|
{u.memo_count}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-300">0</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-400 text-xs">
|
||||||
|
{u.created_at?.slice(0, 10) || "-"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="px-3 py-1 text-sm border rounded disabled:opacity-30"
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{page + 1} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className="px-3 py-1 text-sm border rounded disabled:opacity-30"
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selected User Detail */}
|
||||||
|
{selectedUser && (
|
||||||
|
<div className="bg-surface rounded-lg shadow p-5 space-y-4">
|
||||||
|
<div className="flex items-center gap-3 pb-3 border-b">
|
||||||
|
{selectedUser.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={selectedUser.avatar_url}
|
||||||
|
alt=""
|
||||||
|
className="w-12 h-12 rounded-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-lg font-semibold text-gray-500">
|
||||||
|
{(selectedUser.nickname || selectedUser.email || "?").charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="font-bold">{selectedUser.nickname || "-"}</div>
|
||||||
|
<div className="text-sm text-gray-500">{selectedUser.email || "-"}</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{selectedUser.provider} · 가입: {selectedUser.created_at?.slice(0, 10)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailLoading ? (
|
||||||
|
<p className="text-sm text-gray-500">로딩 중...</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{/* Favorites */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-sm mb-2 text-red-600">
|
||||||
|
찜한 식당 ({favorites.length})
|
||||||
|
</h3>
|
||||||
|
{favorites.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-400">찜한 식당이 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||||
|
{favorites.map((f) => (
|
||||||
|
<div
|
||||||
|
key={f.id}
|
||||||
|
className="flex items-center justify-between border rounded px-3 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{f.name}</span>
|
||||||
|
{f.region && (
|
||||||
|
<span className="ml-1.5 text-gray-400">{f.region}</span>
|
||||||
|
)}
|
||||||
|
{f.cuisine_type && (
|
||||||
|
<span className="ml-1.5 text-gray-400">· {f.cuisine_type}</span>
|
||||||
|
)}
|
||||||
|
{f.business_status === "CLOSED_PERMANENTLY" && (
|
||||||
|
<span className="ml-1.5 px-1 bg-red-100 text-red-600 rounded text-[10px]">
|
||||||
|
폐업
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{f.rating && (
|
||||||
|
<span className="text-yellow-500 shrink-0">
|
||||||
|
★ {f.rating}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reviews */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-sm mb-2 text-brand-600">
|
||||||
|
작성한 리뷰 ({reviews.length})
|
||||||
|
</h3>
|
||||||
|
{reviews.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-400">작성한 리뷰가 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||||
|
{reviews.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className="border rounded px-3 py-2 text-xs space-y-0.5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium">
|
||||||
|
{r.restaurant_name || "알 수 없음"}
|
||||||
|
</span>
|
||||||
|
<span className="text-yellow-500 shrink-0">
|
||||||
|
{"★".repeat(Math.round(r.rating))} {r.rating}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{r.review_text && (
|
||||||
|
<p className="text-gray-600 line-clamp-2">
|
||||||
|
{r.review_text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="text-gray-400 text-[10px]">
|
||||||
|
{r.visited_at && `방문: ${r.visited_at} · `}
|
||||||
|
{r.created_at?.slice(0, 10)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Memos */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-sm mb-2 text-purple-600">
|
||||||
|
작성한 메모 ({memos.length})
|
||||||
|
</h3>
|
||||||
|
{memos.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-400">작성한 메모가 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||||
|
{memos.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="border border-purple-200 rounded px-3 py-2 text-xs space-y-0.5 bg-purple-50/30"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium">
|
||||||
|
{m.restaurant_name || "알 수 없음"}
|
||||||
|
</span>
|
||||||
|
{m.rating && (
|
||||||
|
<span className="text-yellow-500 shrink-0">
|
||||||
|
{"★".repeat(Math.round(m.rating))} {m.rating}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{m.memo_text && (
|
||||||
|
<p className="text-gray-600 line-clamp-2">
|
||||||
|
{m.memo_text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="text-gray-400 text-[10px]">
|
||||||
|
{m.visited_at && `방문: ${m.visited_at} · `}
|
||||||
|
{m.created_at?.slice(0, 10)}
|
||||||
|
<span className="ml-1 text-purple-400">비공개</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 데몬 설정 ─── */
|
||||||
1230
frontend/src/app/admin/_panels/VideosPanel.tsx
Normal file
1230
frontend/src/app/admin/_panels/VideosPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,10 @@
|
|||||||
--brand-800: #9A4500;
|
--brand-800: #9A4500;
|
||||||
--brand-900: #6B3000;
|
--brand-900: #6B3000;
|
||||||
--brand-950: #3D1A00;
|
--brand-950: #3D1A00;
|
||||||
|
/* #344 z-index 토큰 (모달/오버레이가 매직 넘버 없이 일관) */
|
||||||
|
--z-bottom-sheet: 50;
|
||||||
|
--z-filter-sheet: 60;
|
||||||
|
--z-modal: 70;
|
||||||
color-scheme: only light !important;
|
color-scheme: only light !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { GoogleLogin } from "@react-oauth/google";
|
import { GoogleLogin } from "@react-oauth/google";
|
||||||
import LoginMenu from "@/components/LoginMenu";
|
import LoginMenu from "@/components/LoginMenu";
|
||||||
|
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { Restaurant, Channel, Review, Memo } from "@/lib/api";
|
import type { Restaurant, Channel, Review, Memo } from "@/lib/api";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
@@ -168,10 +169,15 @@ function findRegionFromCoords(
|
|||||||
}
|
}
|
||||||
let best: { country: string; city: string } | null = null;
|
let best: { country: string; city: string } | null = null;
|
||||||
let bestDist = Infinity;
|
let bestDist = Infinity;
|
||||||
|
// #320 — 유클리드 거리는 적도/극지에서 경도·위도의 실거리 차이가 커서 왜곡됨.
|
||||||
|
// cos(lat) 가중치(equirectangular approximation)로 위도 의존 보정.
|
||||||
|
const cosLat = Math.cos((lat * Math.PI) / 180);
|
||||||
for (const g of groups.values()) {
|
for (const g of groups.values()) {
|
||||||
const cLat = g.lats.reduce((a, b) => a + b, 0) / g.lats.length;
|
const cLat = g.lats.reduce((a, b) => a + b, 0) / g.lats.length;
|
||||||
const cLng = g.lngs.reduce((a, b) => a + b, 0) / g.lngs.length;
|
const cLng = g.lngs.reduce((a, b) => a + b, 0) / g.lngs.length;
|
||||||
const dist = (cLat - lat) ** 2 + (cLng - lng) ** 2;
|
const dLat = cLat - lat;
|
||||||
|
const dLng = (cLng - lng) * cosLat;
|
||||||
|
const dist = dLat * dLat + dLng * dLng;
|
||||||
if (dist < bestDist) {
|
if (dist < bestDist) {
|
||||||
bestDist = dist;
|
bestDist = dist;
|
||||||
best = { country: g.country, city: g.city };
|
best = { country: g.country, city: g.city };
|
||||||
@@ -379,6 +385,8 @@ export default function Home() {
|
|||||||
const handleSelectRestaurant = useCallback((r: Restaurant) => {
|
const handleSelectRestaurant = useCallback((r: Restaurant) => {
|
||||||
setSelected(r);
|
setSelected(r);
|
||||||
setShowDetail(true);
|
setShowDetail(true);
|
||||||
|
// selected 변경 자체가 MapView 내부의 useEffect에서 중앙 이동을 처리.
|
||||||
|
// (flyTo를 같이 set하면 줌 16으로 강제되어 클러스터 expansion 후 마커 클릭 시 다시 묶임)
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCloseDetail = useCallback(() => {
|
const handleCloseDetail = useCallback(() => {
|
||||||
@@ -714,6 +722,8 @@ export default function Home() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
{/* #352 — Language switcher */}
|
||||||
|
<LanguageSwitcher />
|
||||||
{/* Desktop user area */}
|
{/* Desktop user area */}
|
||||||
{authLoading ? null : user ? (
|
{authLoading ? null : user ? (
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { GoogleOAuthProvider } from "@react-oauth/google";
|
import { GoogleOAuthProvider } from "@react-oauth/google";
|
||||||
import { AuthProvider } from "@/lib/auth-context";
|
import { AuthProvider } from "@/lib/auth-context";
|
||||||
|
import { LocaleProvider } from "@/i18n/LocaleProvider";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
||||||
@@ -9,7 +10,9 @@ const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
|||||||
export function Providers({ children }: { children: ReactNode }) {
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
|
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
|
||||||
<AuthProvider>{children}</AuthProvider>
|
<LocaleProvider>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</LocaleProvider>
|
||||||
</GoogleOAuthProvider>
|
</GoogleOAuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
93
frontend/src/components/FoodIcon.tsx
Normal file
93
frontend/src/components/FoodIcon.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
interface FoodIconProps {
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IconDef {
|
||||||
|
viewBox: string;
|
||||||
|
fill?: boolean; // true = fill icon (no stroke), false = stroke icon (default)
|
||||||
|
paths: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FOOD_ICONS: Record<string, IconDef> = {
|
||||||
|
// 찌개/전골/탕: 뚝배기 + 김
|
||||||
|
"jjigae": {
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
paths: (
|
||||||
|
<>
|
||||||
|
<path d="M5 11h14v1c0 4-2.5 7-7 7s-7-3-7-7v-1z" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M4 11h16" strokeLinecap="round" />
|
||||||
|
<path d="M3 11.5h1M20 11.5h1" strokeLinecap="round" strokeWidth="1.5" />
|
||||||
|
<path d="M9 8c0-1.5 1-2 0-3M12 7c0-1.5 1-2 0-3M15 8c0-1.5 1-2 0-3" strokeLinecap="round" fill="none" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// 분식: 가래떡 1개 사선
|
||||||
|
"tteok": {
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
paths: (
|
||||||
|
<rect x="2" y="10" width="20" height="4" rx="2" transform="rotate(-30 12 12)" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// 면: 그릇에 면+젓가락 (noodles-in-a-bowl)
|
||||||
|
"noodle": {
|
||||||
|
viewBox: "0 0 363.674 363.674",
|
||||||
|
fill: true,
|
||||||
|
paths: (
|
||||||
|
<>
|
||||||
|
<path d="M79.496,292.615c-16.686-10.313-28.229-27.017-33.383-48.308c-0.975-4.025-5.027-6.502-9.054-5.524c-4.026,0.975-6.499,5.027-5.524,9.054c6.102,25.21,19.959,45.105,40.074,57.538c1.229,0.759,2.59,1.121,3.936,1.121c2.514,0,4.969-1.263,6.387-3.558C84.109,299.415,83.02,294.793,79.496,292.615z" />
|
||||||
|
<path d="M363.465,70.469c-0.967-4.027-5.016-6.5-9.045-5.542l-179.247,43.032V85.908l163.056-69.827c3.807-1.631,5.572-6.039,3.941-9.847c-1.631-3.809-6.039-5.574-9.848-3.942L174.755,69.768c-1.017-2.937-3.8-5.049-7.082-5.049c-4.142,0-7.5,3.357-7.5,7.5v3.794l-4.55,1.948c-0.631-3.505-3.689-6.166-7.375-6.166c-4.143,0-7.5,3.357-7.5,7.5v5.036l-4.543,1.945c-0.611-3.527-3.68-6.213-7.382-6.213c-4.142,0-7.5,3.357-7.5,7.5v5.086l-4.497,1.926c-0.482-3.677-3.621-6.517-7.428-6.517c-4.143,0-7.5,3.357-7.5,7.5v5.409l-4.631,1.983c-0.779-3.313-3.745-5.78-7.295-5.78c-4.142,0-7.5,3.357-7.5,7.5v4.615l-20.778,8.898c-3.808,1.631-5.572,6.039-3.942,9.847c1.219,2.846,3.988,4.55,6.898,4.55c0.984,0,1.986-0.195,2.949-0.607l14.873-6.369v4.608l-14.318,3.438c-4.027,0.967-6.509,5.016-5.542,9.044c0.825,3.439,3.899,5.751,7.286,5.751c0.58,0,1.17-0.068,1.758-0.209l10.816-2.597v64.284H7.5c-4.142,0-7.5,3.357-7.5,7.5c0,28.225,6.892,54.242,19.93,75.239c12.92,20.807,31.711,36.318,54.55,45.096v16.73c0,4.143,3.358,7.5,7.5,7.5h97.493c4.143,0,7.5-3.357,7.5-7.5v-16.73c22.838-8.777,41.631-24.29,54.549-45.096c13.039-20.997,19.932-47.015,19.932-75.239c0-4.143-3.358-7.5-7.5-7.5H116.898v-72.549l4.719-1.133c0.898,3.138,3.781,5.436,7.206,5.436c4.142,0,7.5-3.357,7.5-7.5v-1.466l4.471-1.074c0.405,3.76,3.587,6.688,7.454,6.688c4.143,0,7.5-3.357,7.5-7.5v-2.777l4.425-1.063v14.33c0,4.143,3.358,7.5,7.5,7.5s7.5-3.357,7.5-7.5v-17.931l182.749-43.873C361.949,78.546,364.432,74.497,363.465,70.469z M97.473,119.181l4.426-1.895v8.264l-4.426,1.063V119.181z M89.479,346.99v-7.19h82.493v7.19H89.479z M178.76,324.8H82.693c-40.367-14.153-65.217-51.11-67.519-99.875h231.103C243.975,273.689,219.127,310.646,178.76,324.8z M101.898,209.925h-4.426v-67.886l4.426-1.063V209.925z M116.898,110.863l4.425-1.895v11.918l-4.425,1.063V110.863z M136.323,102.545l4.425-1.896v15.574l-4.425,1.063V102.545z M155.748,112.623V94.226l4.425-1.896v19.229L155.748,112.623z" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// 텐동/튀김: 텐푸라 (flaticon tempura)
|
||||||
|
"tempura": {
|
||||||
|
viewBox: "0 0 64 64",
|
||||||
|
fill: true,
|
||||||
|
paths: (
|
||||||
|
<>
|
||||||
|
<path d="M59.96092,38.28982c-.58769-2.348-4.12861-1.72934-6.10276-1.50418a48.12688,48.12688,0,0,0,2.50872-4.73925A1.91121,1.91121,0,0,0,54.08314,29.416c-3.94455,1.2-6.79557,4.77253-8.49153,7.55764a8.05234,8.05234,0,0,1-1.77036,2.03025c-.05638-.04614-.115-.097-.16482-.13169a5.09683,5.09683,0,0,0-5.11816-.48233,4.7649,4.7649,0,0,0-5.21482-.55654,5.28288,5.28288,0,0,0-3.90841-.05207,4.9359,4.9359,0,0,0-4.18868-1.25476A4.63263,4.63263,0,0,0,21.0592,34.644a4.73344,4.73344,0,0,0-8.3529-1.07386c-2.98316-.34766-6.15924,2.35525-5.34459,5.723A5.18758,5.18758,0,0,0,5.5691,43.06339a4.84285,4.84285,0,0,0,3.72,4.64886,4.72272,4.72272,0,0,0,5.13667,4.69644A4.74936,4.74936,0,0,0,20.736,54.771a4.73286,4.73286,0,0,0,6.6833,1.47475.99956.99956,0,1,0-1.1287-1.65,2.71212,2.71212,0,0,1-3.84593-.86364,2.05527,2.05527,0,0,0-2.603-.75617,2.5586,2.5586,0,0,1-1.09452.24556,2.73778,2.73778,0,0,1-2.67039-2.17292,1.00314,1.00314,0,0,0-.46182-.65271c-.46123-.33887-1.17682.06863-1.60712.03614a2.72229,2.72229,0,0,1-2.7192-2.71971c-.00244-.45741.32815-1.1176-.06741-1.54852a1.00725,1.00725,0,0,0-.93241-.38128,2.72268,2.72268,0,0,1-1.6968-4.8248,2.11365,2.11365,0,0,0,.72141-2.09722c-.55724-1.9936,1.76582-3.57014,2.96729-3.2786a2.07918,2.07918,0,0,0,2.08545-.89638,2.709,2.709,0,0,1,4.94632,1.24537,1.00583,1.00583,0,0,0,1.30933.8426,2.64386,2.64386,0,0,1,3.00723.9549,2.065,2.065,0,0,0,2.04257.78843,2.793,2.793,0,0,1,2.357.70543,2.12238,2.12238,0,0,0,2.32378.41545,2.67127,2.67127,0,0,1,2.48878.1982.99706.99706,0,0,0,1.10819-.0415,2.72809,2.72809,0,0,1,3.29624.12156,2.05987,2.05987,0,0,0,2.27495.27241,2.70644,2.70644,0,0,1,3.72479,3.33392c-.38932.81316.21723,1.64631.15282,2.47613A2.71632,2.71632,0,0,1,40.1902,48.528a2.00549,2.00549,0,0,0-2.17527,1.16787,2.71478,2.71478,0,0,1-2.49171,1.64568,2.592,2.592,0,0,1-.949-.17282,2.03329,2.03329,0,0,0-2.42337.79575,2.73019,2.73019,0,0,1-1.58075,1.17262.99911.99911,0,0,0-.69908,1.22877c.77166,1.84716,3.33875-.273,3.98066-1.33265A4.69753,4.69753,0,0,0,39.828,50.49447,4.90789,4.90789,0,0,0,44.20519,49.091c1.07331-1.28142.69028-2.47595,2.314-3.18146a7.17839,7.17839,0,0,1,2.02786-.83393,32.47063,32.47063,0,0,0,10.64445-4.85112A1.88349,1.88349,0,0,0,59.96092,38.28982ZM48.05305,43.138a8.88032,8.88032,0,0,0-2.879,1.26343c.07057-1.027.56233-2.1911-.21569-3.75184,3.30834-2.7934,4.28439-7.41256,9.50762-9.25727A41.37828,41.37828,0,0,1,50.91767,37.626c-1.32533.664-3.54,1.03493-2.8774,2.52866a.99875.99875,0,0,0,1.36213.37843c2.1403-1.32937,6.2594-2.42108,8.52181-1.86132A30.48308,30.48308,0,0,1,48.05305,43.138Z" />
|
||||||
|
<path d="M20.71747,49.98436A3.136,3.136,0,0,0,24.414,47.5605a1.00042,1.00042,0,0,0-1.95861-.40708,1.12553,1.12553,0,0,1-2.20366-.45852,1.00043,1.00043,0,0,0-1.95863-.407A3.12939,3.12939,0,0,0,20.71747,49.98436Z" />
|
||||||
|
<path d="M33.96005,46.60805a1.13194,1.13194,0,0,1-1.5456-.38031,1.00012,1.00012,0,0,0-1.71058,1.036,3.126,3.126,0,0,0,5.34752-3.23828,1.0001,1.0001,0,0,0-1.71052,1.03606A1.13019,1.13019,0,0,1,33.96005,46.60805Z" />
|
||||||
|
<path d="M14.89436,39.24667A1.131,1.131,0,0,1,16.44,39.627a1.00011,1.00011,0,0,0,1.71058-1.036A3.12612,3.12612,0,0,0,12.803,41.82975a1.00009,1.00009,0,0,0,1.71052-1.03606A1.13084,1.13084,0,0,1,14.89436,39.24667Z" />
|
||||||
|
<path d="M6.12651,33.96448a2.694,2.694,0,0,1,.36907-2.33061c1.05665-1.50674-.70244-2.64347-.19038-4.31314C7.64257,24.515,9.289,26.224,10.16584,23.95766a2.71747,2.71747,0,0,1,5.09174-.25918,1.00613,1.00613,0,0,0,1.51436.41191,2.23774,2.23774,0,0,1,.74692-.37979,2.64262,2.64262,0,0,1,2.39017.41545,2.05437,2.05437,0,0,0,2.192.15622,2.71372,2.71372,0,0,1,2.47706-.00635,2.09934,2.09934,0,0,0,2.31206-.28413,2.64664,2.64664,0,0,1,2.44094-.54725,1.00237,1.00237,0,0,0,1.04765-.35882,2.68307,2.68307,0,0,1,3.20154-.85528,2.04549,2.04549,0,0,0,2.23589-.40278c.466-.57119,2.41006-1.24944,3.47261-.19757a2.77124,2.77124,0,0,1,1.07535,2.30556,2.12056,2.12056,0,0,0,.42765,1.36351,2.71658,2.71658,0,0,1-1.84936,4.30037,2.00726,2.00726,0,0,0-1.774,1.76536,2.64106,2.64106,0,0,1-1.3718,2.06064,1.00024,1.00024,0,0,0,.953,1.75842,4.56245,4.56245,0,0,0,2.38621-3.594,4.8526,4.8526,0,0,0,3.78058-2.63716,5.43994,5.43994,0,0,0,.35986-2.5729,8.82313,8.82313,0,0,1,2.61707-2.53306A32.49913,32.49913,0,0,0,54.64846,16.108a1.88292,1.88292,0,0,0,.167-2.07578c-1.24835-2.07129-4.45549-.44274-6.275.35141a48.01336,48.01336,0,0,0,1.00846-5.265,1.91029,1.91029,0,0,0-2.95353-1.84583c-3.42122,2.30473-5.09863,6.55538-5.90317,9.71591-.21291.58392-.67229,2.46176-1.29549,2.3801a5.14553,5.14553,0,0,0-5.03435,1.04112,4.69673,4.69673,0,0,0-5.14842.99789,5.16687,5.16687,0,0,0-3.75118,1.09557,4.88152,4.88152,0,0,0-4.37323.02872,4.60545,4.60545,0,0,0-4.14375-.71718,3.74334,3.74334,0,0,0-.39739.1362A4.70187,4.70187,0,0,0,8.256,23.365a5.23681,5.23681,0,0,0-3.69162,2.97413,4.56732,4.56732,0,0,0,.26544,4.08018,5.13094,5.13094,0,0,0-.62272,4.1052A1.01478,1.01478,0,1,0,6.12651,33.96448Zm41.413-24.91369a41.36518,41.36518,0,0,1-1.56268,6.99793,18.57825,18.57825,0,0,0-2.04893,1.8495.99993.99993,0,0,0,1.45289,1.37371c1.65257-1.89613,5.27564-4.14923,7.60011-4.27732a30.49647,30.49647,0,0,1-8.12739,7.16451A8.87242,8.87242,0,0,0,42.465,24.21738a5.82025,5.82025,0,0,0-1.30541-3.52356C43.51355,17.05538,43.06651,12.37112,47.53947,9.05079Z" />
|
||||||
|
<path d="M31.62065,29.98282a1.14811,1.14811,0,0,1-.81332-.28461,1.00012,1.00012,0,0,0-1.33173,1.49194,3.10428,3.10428,0,0,0,2.07768.79424,3.14507,3.14507,0,0,0,2.08541-5.458,1.00008,1.00008,0,0,0-1.3316,1.492A1.13309,1.13309,0,0,1,31.62065,29.98282Z" />
|
||||||
|
<path d="M12.78637,27.78646a1.13979,1.13979,0,0,1,.81332.28462,1.00013,1.00013,0,0,0,1.33173-1.49195,3.12577,3.12577,0,0,0-4.16309,4.66374,1.00009,1.00009,0,0,0,1.3316-1.492A1.13318,1.13318,0,0,1,12.78637,27.78646Z" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// 돼지 (삼겹살/돼지구이, 족발/보쌈, 돈카츠)
|
||||||
|
"pig": {
|
||||||
|
viewBox: "0 0 90 90",
|
||||||
|
fill: true,
|
||||||
|
paths: (
|
||||||
|
<>
|
||||||
|
<path d="m66.005 57.841c-.76 3.428-2.077 6.355-3.24 8.521h-2.301c.645-2.74 1.151-5.421 1.312-7.973 1.428-.158 2.844-.334 4.229-.548zm-27.672-.004c.745.129 1.505.239 2.265.348.147 2.609.667 5.365 1.324 8.177h-2.344c-.729-1.359-1.505-2.968-2.172-4.901.355-1.156.672-2.37.927-3.624zm27.09-33.334c8.703-.376 14.869 5.025 17.547 10.541 2.765 5.713.796 11.839-3.765 16.443-.209.208-.328.489-.339.781-.224 6.339-2.589 12.077-4.584 15.739h-3.864c.765-3.176 1.396-6.301 1.572-9.244.032-.547-.323-1.041-.853-1.188-3.975-1.072-5.729-3.661-6.443-6.213-.407-1.511-2.667-.88-2.24.62.349 1.245 1.161 2.469 2.041 3.645-9.041 1.261-17.015 1.464-25.776-.088.219-1.615.328-3.287.276-4.989.011-1.604-2.421-1.532-2.317.068.199 6.905-2.432 13.395-4.609 17.389h-3.844c1.532-6.337 2.12-12.307 1.089-17.645-.1-.557-.589-.964-1.152-.959-.739 0-1.285.677-1.135 1.396.183.937.296 1.912.38 2.901-4.984-.828-8.287-2.407-11.203-4.027-3.157-1.755-5.933-3.624-9.776-4.385-.048-.009-.183-.067-.355-.395-.172-.329-.312-.855-.344-1.396-.025-.543.057-1.095.199-1.469.145-.369.307-.505.411-.541 4.541-1.765 7.147-3.251 11.161-7.683.396-.443.401-1.109.005-1.552-2.077-2.349-2.219-4.749-1.869-7.177l8.244 6.371c1.224.948 2.647-.891 1.423-1.839l-1-.771c1.296-1.172 2.421-2.256 6.073-3.172 4.364-1.089 8.124-.729 13.389-.376 5.272.349 9.917.663 19.887-.629.599-.079 1.192-.131 1.771-.156zm14.598-4.849c-1.547.073-1.437 2.396.115 2.323 1.817 0 2.536 1.063 2.599 2.344.057 1.244-.625 2.64-2.583 3.24-4.048-3.667-9.797-6.109-16.803-5.199-9.787 1.265-14.197.953-19.427.604-5.235-.348-9.324-.749-14.104.448-4.167 1.041-5.891 2.489-7.443 3.932l-6.964-5.385c-.219-.167-.484-.255-.756-.244-.509.02-.943.375-1.077.864-.823 3.109-.667 6.781 1.683 10.136-3.491 3.739-5.568 4.963-9.772 6.599-.891.344-1.432 1.115-1.728 1.88-.297.771-.396 1.599-.349 2.423.041.823.224 1.629.604 2.353.374.718 1.026 1.416 1.964 1.598 3.339.661 5.833 2.319 9.093 4.131 3.099 1.724 6.875 3.505 12.443 4.364.041 3.948-.584 8.235-1.797 12.803-.197.74.36 1.464 1.125 1.459h5.989c.423 0 .808-.224 1.016-.589.765-1.359 1.588-3.052 2.36-4.952.536 1.244 1.088 2.369 1.609 3.301.203.369.593.599 1.009.599h4.448c.767.005 1.323-.719 1.131-1.457-.808-3.079-1.303-5.996-1.464-8.745 6.043.672 10.36.672 16.495.14-.172 2.709-.661 5.579-1.452 8.605-.199.739.364 1.463 1.124 1.457h4.448c.423 0 .813-.229 1.016-.599 1.281-2.297 2.765-5.631 3.645-9.489.412.244.849.473 1.344.661-.229 3.041-.744 6.235-1.64 9.609-.199.735.359 1.459 1.12 1.459h5.995c.416 0 .801-.224 1.005-.589 2.145-3.796 4.771-10.063 5.088-17.093 4.792-5.088 7.167-12.131 3.937-18.615-.891-1.776-2.052-3.427-3.156-4.692 2.135-1.1 3.24-3.167 3.145-5.131-.109-2.333-2.025-4.552-4.921-4.552-.04-.001-.078-.001-.114-.001z" />
|
||||||
|
<path clipRule="evenodd" d="m18.354 36.679c1.177 0 2.135.959 2.135 2.141 0 1.183-.957 2.14-2.135 2.14-1.183 0-2.141-.957-2.141-2.14 0-1.182.959-2.141 2.141-2.141z" fillRule="evenodd" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FoodIcon({ name, size = 24, className = "" }: FoodIconProps) {
|
||||||
|
const icon = FOOD_ICONS[name];
|
||||||
|
if (!icon) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox={icon.viewBox}
|
||||||
|
fill={icon.fill ? "currentColor" : "none"}
|
||||||
|
stroke={icon.fill ? "none" : "currentColor"}
|
||||||
|
strokeWidth={icon.fill ? undefined : 1.5}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{icon.paths}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
396
frontend/src/components/GoogleMapView.tsx
Normal file
396
frontend/src/components/GoogleMapView.tsx
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
APIProvider,
|
||||||
|
Map,
|
||||||
|
AdvancedMarker,
|
||||||
|
InfoWindow,
|
||||||
|
useMap,
|
||||||
|
} from "@vis.gl/react-google-maps";
|
||||||
|
import Supercluster from "supercluster";
|
||||||
|
import type { Restaurant } from "@/lib/api";
|
||||||
|
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||||
|
import Icon from "@/components/Icon";
|
||||||
|
|
||||||
|
const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 };
|
||||||
|
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
|
||||||
|
|
||||||
|
// Channel color palette
|
||||||
|
const CHANNEL_COLORS = [
|
||||||
|
{ bg: "#fff7ed", text: "#78350f", border: "#f59e0b", arrow: "#f59e0b" }, // amber (default)
|
||||||
|
{ bg: "#eff6ff", text: "#1e3a5f", border: "#3b82f6", arrow: "#3b82f6" }, // blue
|
||||||
|
{ bg: "#f0fdf4", text: "#14532d", border: "#22c55e", arrow: "#22c55e" }, // green
|
||||||
|
{ bg: "#fdf2f8", text: "#831843", border: "#ec4899", arrow: "#ec4899" }, // pink
|
||||||
|
{ bg: "#faf5ff", text: "#581c87", border: "#a855f7", arrow: "#a855f7" }, // purple
|
||||||
|
{ bg: "#fff1f2", text: "#7f1d1d", border: "#ef4444", arrow: "#ef4444" }, // red
|
||||||
|
{ bg: "#f0fdfa", text: "#134e4a", border: "#14b8a6", arrow: "#14b8a6" }, // teal
|
||||||
|
{ bg: "#fefce8", text: "#713f12", border: "#eab308", arrow: "#eab308" }, // yellow
|
||||||
|
];
|
||||||
|
|
||||||
|
function getChannelColorMap(restaurants: Restaurant[]) {
|
||||||
|
const channels = new Set<string>();
|
||||||
|
restaurants.forEach((r) => r.channels?.forEach((ch) => channels.add(ch)));
|
||||||
|
const map: Record<string, typeof CHANNEL_COLORS[0]> = {};
|
||||||
|
let i = 0;
|
||||||
|
for (const ch of channels) {
|
||||||
|
map[ch] = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
import type { MapBounds, FlyTo, MapViewProps } from "@/components/MapView.types";
|
||||||
|
|
||||||
|
type RestaurantProps = { restaurant: Restaurant };
|
||||||
|
type RestaurantFeature = Supercluster.PointFeature<RestaurantProps>;
|
||||||
|
|
||||||
|
function useSupercluster(restaurants: Restaurant[]) {
|
||||||
|
// #278 — indexRef 제거 (set만 되고 read 없는 dead code)
|
||||||
|
const points: RestaurantFeature[] = useMemo(
|
||||||
|
() =>
|
||||||
|
restaurants.map((r) => ({
|
||||||
|
type: "Feature" as const,
|
||||||
|
geometry: { type: "Point" as const, coordinates: [r.longitude, r.latitude] },
|
||||||
|
properties: { restaurant: r },
|
||||||
|
})),
|
||||||
|
[restaurants]
|
||||||
|
);
|
||||||
|
|
||||||
|
const index = useMemo(() => {
|
||||||
|
const sc = new Supercluster<{ restaurant: Restaurant }>({
|
||||||
|
radius: 60,
|
||||||
|
maxZoom: 16,
|
||||||
|
minPoints: 2,
|
||||||
|
});
|
||||||
|
sc.load(points);
|
||||||
|
return sc;
|
||||||
|
}, [points]);
|
||||||
|
|
||||||
|
const getClusters = useCallback(
|
||||||
|
(bounds: MapBounds, zoom: number) => {
|
||||||
|
return index.getClusters(
|
||||||
|
[bounds.west, bounds.south, bounds.east, bounds.north],
|
||||||
|
Math.floor(zoom)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[index]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getExpansionZoom = useCallback(
|
||||||
|
(clusterId: number): number => {
|
||||||
|
try {
|
||||||
|
return index.getClusterExpansionZoom(clusterId);
|
||||||
|
} catch {
|
||||||
|
return 17;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[index]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { getClusters, getExpansionZoom, index };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClusterSize(count: number): number {
|
||||||
|
if (count < 10) return 36;
|
||||||
|
if (count < 50) return 42;
|
||||||
|
if (count < 100) return 48;
|
||||||
|
return 54;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeChannel }: Omit<MapViewProps, "onMyLocation" | "onBoundsChanged">) {
|
||||||
|
const map = useMap();
|
||||||
|
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
|
||||||
|
const [zoom, setZoom] = useState(13);
|
||||||
|
const [bounds, setBounds] = useState<MapBounds | null>(null);
|
||||||
|
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||||
|
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
|
||||||
|
|
||||||
|
// #278 — restaurantMap 제거 (빌드만 되고 렌더에서 사용 안 됨, dead code)
|
||||||
|
|
||||||
|
const clusters = useMemo(() => {
|
||||||
|
if (!bounds) return [];
|
||||||
|
return getClusters(bounds, zoom);
|
||||||
|
}, [bounds, zoom, getClusters]);
|
||||||
|
|
||||||
|
const handleMarkerClick = useCallback(
|
||||||
|
(r: Restaurant) => {
|
||||||
|
setInfoTarget(r);
|
||||||
|
onSelectRestaurant?.(r);
|
||||||
|
},
|
||||||
|
[onSelectRestaurant]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClusterClick = useCallback(
|
||||||
|
(clusterId: number, lng: number, lat: number) => {
|
||||||
|
if (!map) return;
|
||||||
|
const expansionZoom = Math.min(getExpansionZoom(clusterId), 18);
|
||||||
|
map.panTo({ lat, lng });
|
||||||
|
map.setZoom(expansionZoom);
|
||||||
|
},
|
||||||
|
[map, getExpansionZoom]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track camera changes for clustering
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
const listener = map.addListener("idle", () => {
|
||||||
|
const b = map.getBounds();
|
||||||
|
const z = map.getZoom();
|
||||||
|
if (b && z != null) {
|
||||||
|
const ne = b.getNorthEast();
|
||||||
|
const sw = b.getSouthWest();
|
||||||
|
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
|
||||||
|
setZoom(z);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Trigger initial bounds
|
||||||
|
const b = map.getBounds();
|
||||||
|
const z = map.getZoom();
|
||||||
|
if (b && z != null) {
|
||||||
|
const ne = b.getNorthEast();
|
||||||
|
const sw = b.getSouthWest();
|
||||||
|
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
|
||||||
|
setZoom(z);
|
||||||
|
}
|
||||||
|
return () => google.maps.event.removeListener(listener);
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
// Fly to a specific location (region filter)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map || !flyTo) return;
|
||||||
|
map.panTo({ lat: flyTo.lat, lng: flyTo.lng });
|
||||||
|
if (flyTo.zoom) map.setZoom(flyTo.zoom);
|
||||||
|
}, [map, flyTo]);
|
||||||
|
|
||||||
|
// Pan and zoom to selected restaurant — 현재 줌이 16 이상이면 유지(클러스터 expansion 후 재묶임 방지)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map || !selected) return;
|
||||||
|
map.panTo({ lat: selected.latitude, lng: selected.longitude });
|
||||||
|
const currentZoom = map.getZoom() ?? 13;
|
||||||
|
if (currentZoom < 16) map.setZoom(16);
|
||||||
|
setInfoTarget(selected);
|
||||||
|
}, [map, selected]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{clusters.map((feature) => {
|
||||||
|
const [lng, lat] = feature.geometry.coordinates;
|
||||||
|
const isCluster = feature.properties && "cluster" in feature.properties && feature.properties.cluster;
|
||||||
|
|
||||||
|
if (isCluster) {
|
||||||
|
const { cluster_id, point_count } = feature.properties as Supercluster.ClusterProperties;
|
||||||
|
const size = getClusterSize(point_count);
|
||||||
|
return (
|
||||||
|
<AdvancedMarker
|
||||||
|
key={`cluster-${cluster_id}`}
|
||||||
|
position={{ lat, lng }}
|
||||||
|
onClick={() => handleClusterClick(cluster_id, lng, lat)}
|
||||||
|
zIndex={100}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
aria-label={`${point_count}개 식당이 모인 클러스터, 클릭하면 확대됩니다`}
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "linear-gradient(135deg, #E8720C 0%, #f59e0b 100%)",
|
||||||
|
border: "3px solid #fff",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.25)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: size > 42 ? 15 : 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "transform 0.2s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{point_count}
|
||||||
|
</div>
|
||||||
|
</AdvancedMarker>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual marker
|
||||||
|
const r = (feature.properties as { restaurant: Restaurant }).restaurant;
|
||||||
|
const isSelected = selected?.id === r.id;
|
||||||
|
const isClosed = r.business_status === "CLOSED_PERMANENTLY";
|
||||||
|
const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
|
||||||
|
const chColor = chKey ? channelColors[chKey] : CHANNEL_COLORS[0];
|
||||||
|
const c = chColor || CHANNEL_COLORS[0];
|
||||||
|
return (
|
||||||
|
<AdvancedMarker
|
||||||
|
key={r.id}
|
||||||
|
position={{ lat: r.latitude, lng: r.longitude }}
|
||||||
|
onClick={() => handleMarkerClick(r)}
|
||||||
|
zIndex={isSelected ? 1000 : 1}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
aria-label={`${r.name}${isClosed ? ' (폐업)' : ''}, 클릭하면 상세 정보가 표시됩니다`}
|
||||||
|
style={{ display: "flex", flexDirection: "column", alignItems: "center", transition: "transform 0.2s ease", transform: isSelected ? "scale(1.15)" : "scale(1)", opacity: isClosed ? 0.5 : 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "4px 8px",
|
||||||
|
backgroundColor: isSelected ? "#2563eb" : isClosed ? "#f3f4f6" : c.bg,
|
||||||
|
color: isSelected ? "#fff" : isClosed ? "#9ca3af" : c.text,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: isSelected ? "2px solid #1d4ed8" : `1.5px solid ${c.border}`,
|
||||||
|
boxShadow: isSelected
|
||||||
|
? "0 2px 8px rgba(37,99,235,0.4)"
|
||||||
|
: `0 1px 4px ${c.border}40`,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
maxWidth: 120,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
textDecoration: isClosed ? "line-through" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
borderLeft: "6px solid transparent",
|
||||||
|
borderRight: "6px solid transparent",
|
||||||
|
borderTop: isSelected ? "6px solid #1d4ed8" : `6px solid ${c.arrow}`,
|
||||||
|
marginTop: -1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AdvancedMarker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{infoTarget && (
|
||||||
|
<InfoWindow
|
||||||
|
position={{ lat: infoTarget.latitude, lng: infoTarget.longitude }}
|
||||||
|
onCloseClick={() => setInfoTarget(null)}
|
||||||
|
>
|
||||||
|
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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" && (
|
||||||
|
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold">폐업</span>
|
||||||
|
)}
|
||||||
|
{infoTarget.business_status === "CLOSED_TEMPORARILY" && (
|
||||||
|
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded text-[10px] font-semibold">임시휴업</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{infoTarget.rating && (
|
||||||
|
<p className="text-xs mt-0.5">
|
||||||
|
<span className="text-yellow-500">★</span> {infoTarget.rating}
|
||||||
|
{infoTarget.rating_count && (
|
||||||
|
<span className="text-gray-400 ml-1">({infoTarget.rating_count.toLocaleString()})</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{infoTarget.cuisine_type && (
|
||||||
|
<p className="text-xs text-gray-500">{infoTarget.cuisine_type}</p>
|
||||||
|
)}
|
||||||
|
{infoTarget.address && (
|
||||||
|
<p className="text-[11px] text-gray-400 mt-1">{infoTarget.address}</p>
|
||||||
|
)}
|
||||||
|
{infoTarget.price_range && (
|
||||||
|
<p className="text-[11px] text-gray-400">{infoTarget.price_range}</p>
|
||||||
|
)}
|
||||||
|
{infoTarget.phone && (
|
||||||
|
<p className="text-[11px] text-gray-400">{infoTarget.phone}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectRestaurant?.(infoTarget)}
|
||||||
|
className="mt-2 text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
상세 보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</InfoWindow>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GoogleMapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo, onMyLocation, activeChannel }: MapViewProps) {
|
||||||
|
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||||
|
const channelNames = useMemo(() => {
|
||||||
|
const names = Object.keys(channelColors);
|
||||||
|
if (activeChannel) return names.filter((n) => n === activeChannel);
|
||||||
|
return names;
|
||||||
|
}, [channelColors, activeChannel]);
|
||||||
|
const boundsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const handleCameraChanged = useCallback((ev: { detail: { bounds: { north: number; south: number; east: number; west: number } } }) => {
|
||||||
|
if (!onBoundsChanged) return;
|
||||||
|
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
||||||
|
boundsTimerRef.current = setTimeout(() => {
|
||||||
|
const { north, south, east, west } = ev.detail.bounds;
|
||||||
|
onBoundsChanged({ north, south, east, west });
|
||||||
|
}, 150);
|
||||||
|
}, [onBoundsChanged]);
|
||||||
|
|
||||||
|
// #278 — 언마운트 시 디바운스 타이머 정리 (메모리 누수 + unmounted setState 경고 방지)
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<APIProvider apiKey={API_KEY}>
|
||||||
|
<Map
|
||||||
|
defaultCenter={SEOUL_CENTER}
|
||||||
|
defaultZoom={13}
|
||||||
|
mapId="tasteby-map"
|
||||||
|
className="h-full w-full"
|
||||||
|
colorScheme="LIGHT"
|
||||||
|
mapTypeControl={false}
|
||||||
|
fullscreenControl={false}
|
||||||
|
onCameraChanged={handleCameraChanged}
|
||||||
|
>
|
||||||
|
<MapContent
|
||||||
|
restaurants={restaurants}
|
||||||
|
selected={selected}
|
||||||
|
onSelectRestaurant={onSelectRestaurant}
|
||||||
|
flyTo={flyTo}
|
||||||
|
activeChannel={activeChannel}
|
||||||
|
/>
|
||||||
|
</Map>
|
||||||
|
{onMyLocation && (
|
||||||
|
<button
|
||||||
|
onClick={onMyLocation}
|
||||||
|
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="내 위치"
|
||||||
|
>
|
||||||
|
<Icon name="my_location" size={22} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{channelNames.length > 0 && (
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="채널 범례"
|
||||||
|
className="absolute bottom-2 left-2 bg-surface/90 backdrop-blur-sm rounded-lg shadow px-2.5 py-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] z-10"
|
||||||
|
>
|
||||||
|
{channelNames.map((ch) => (
|
||||||
|
<div key={ch} className="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="inline-block w-2.5 h-2.5 rounded-full border"
|
||||||
|
style={{ backgroundColor: channelColors[ch].border, borderColor: channelColors[ch].border }}
|
||||||
|
/>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">{ch}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</APIProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ export default function Icon({ name, size = 20, filled, className = "" }: IconPr
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`material-symbols-rounded ${filled ? "filled" : ""} ${className}`}
|
className={`material-symbols-rounded ${filled ? "filled" : ""} ${className}`}
|
||||||
style={{ fontSize: size }}
|
style={{ fontSize: size, width: size, height: size, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center" }}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user