Compare commits

...

97 Commits

Author SHA1 Message Date
joungmin
247547c516 feat(map): NaverMapView 채널별 마커 색상
- GoogleMapView와 동일 팔레트 (8 색)
- 식당 첫 채널 기준 색상, activeChannel 있으면 우선
- getChannelColorMap 재사용 패턴 동일

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 17:09:02 +09:00
joungmin
8de8696424 perf(map): NaverMapView SDK 네이티브 마커 + InfoWindow
- React absolute div overlay → naver.maps.Marker 네이티브 교체
- 줌/팬 시 SDK GPU 최적화로 랙 해소
- 식당명 InfoWindow (마커 클릭 시 표시)
- bounds_changed → idle 이벤트로 sync 빈도 감소
- 클러스터도 네이티브 마커 (HTML 콘텐츠 숫자)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 16:58:03 +09:00
joungmin
a4de9ba87b feat(admin): #356 후속 — verifyAllAsync로 즉시 응답
- 1244 링크 백필 동기 호출 시 HTTP 25~30분 hang (ingress 5분 timeout 초과)
- @Async verifyAllAsync 추가, controller는 started/pending 즉시 응답
- 진행은 /api/admin/video-relevance/pending 폴링으로 확인

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 15:15:48 +09:00
joungmin
cf37e496d4 docs(design): #363 실 운영 fix 기록 (1~2단계 + v0.1.57 안정화)
- 배포 흐름 v0.1.51~v0.1.57 정리
- 운영 진단 확인사항 (NCLOUD 도메인 형식, Search/Maps 별개 시스템)
- NaverMapView 안정화 핵심 결정사항 (divRef 항상 마운트, ResizeObserver, try/catch)
- 후속 분리 항목 명시

Refs: #363

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 10:56:51 +09:00
joungmin
ce3e34938c fix(map): NaverMapView 안정화 — divRef 항상 마운트 + ResizeObserver
- 이전: ready 가드로 첫 렌더 시 ref 누락 가능 → SDK가 div 못 잡음
- 명시적 width/height + 회색 배경(시각적 로딩 표시)
- ResizeObserver + rAF로 컨테이너 0×0 → 정상 크기 시 m.refresh
- try/catch + initError로 init 실패 가시화

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 10:38:57 +09:00
joungmin
5199475d67 revert(map): NaverMap 임시 비활성, 한국도 GoogleMap fallback
- NaverMapView 골격이 실 운영에서 지도/마커 렌더 실패
- 환경변수 비워 dispatcher가 GoogleMap fallback (회귀 0)
- NaverMapView 코드는 유지 — 후속 안정화 작업 후 재활성

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 10:32:54 +09:00
joungmin
bd8d82dd5d fix(stats): /api/stats/visits 500 — Mapper resultType int→long
- StatsMapper interface는 long 반환인데 XML resultType이 int
- Integer를 primitive long으로 cast 못 함 → ClassCastException → 500

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 10:26:15 +09:00
joungmin
bc83923261 fix(map): NaverMap 인증 파라미터 ncpClientId → ncpKeyId
- NCLOUD 신 정책: ncpKeyId 사용 (navermaps/maps.js.ncp 공식)
- 인증 200/Failed 진짜 원인 — 도메인 등록 정확했으나 파라미터 차이
- Refs: navermaps/maps.js.ncp

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 10:03:09 +09:00
joungmin
f17ba9e37a feat(map): #363 메인 지도 SDK 국내(네이버)/해외(구글) 분기
- MapView dispatcher: NAVER 키 + KR bbox 좌표 → NaverMapView
- NaverMapView 신규 (네이버 v3 직접 wrapper, Supercluster 재사용)
- GoogleMapView 신규 (기존 MapView 내용 rename)
- MapView.types.ts 공용 타입 + isKoreaCoord 헬퍼
- Dockerfile/deploy.sh: NEXT_PUBLIC_NAVER_MAP_CLIENT_ID build-arg
- 키 미설정 시 GoogleMap fallback (회귀 0)

Refs: #363

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 06:25:47 +09:00
joungmin
7789671fbc feat(map): 식당 상세 지도 링크 국내/해외 분기 (1단계)
- 좌표 기반 한국 판정 (KR bbox 33~38.7°N, 124~132°E)
- 국내: 네이버 지도(/p/search/) primary + Google Maps 보조
- 해외: Google Maps 단독
- 좌표 없으면 region 첫 토큰 fallback

2단계(메인 지도 탭 SDK 분기)는 별도 후속.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 05:59:24 +09:00
joungmin
c5b0216a37 fix(catchtable): URL 패턴을 /ct/shop/, /ct/dining/으로 교정
- 실제 캐치테이블은 app.catchtable.co.kr/ct/shop/... 형식
- 옛 /shop/, /dining/ 패턴은 contains 매칭 실패 → 첫 회차 1044건 전부 미발견
- 패턴 교정 후 NONE 해제 + 재실행 필요

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 01:35:18 +09:00
joungmin
40e448fe95 fix(search): WebSearchService HTTP timeout 추가 (connect 5s, request 15s)
- 특정 검색에서 무한 hang → backend virtual thread 점유로 후속 벌크 작업 중단
- Naver/DDG 둘 다 timeout 적용
- 타임아웃 시 HttpTimeoutException → 호출자(bulk)에서 notfound 안전 처리

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 21:52:31 +09:00
joungmin
8a21646031 fix(admin): bulk-tabling/catchtable SSE timeout 10분 → 3시간
- 대량 백필(700+건 ≈ 100분) 시 10분 SSE timeout으로 중간 끊김
- emit() 실패 시 마지막 cache.flush + complete 누락 → 3시간으로 확장

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 21:08:55 +09:00
joungmin
52090057de fix(restaurant): #357 후속 — tabling-url validation에 www. 호스트 허용
- Naver/DDG 결과가 www.tabling.co.kr 형태인데 PUT validation에서 거부됨
- bulk-tabling SSE는 validation 없이 통과 — 불일치 해소
- catchtable은 이미 app/www 둘 다 허용 (기존)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 21:02:32 +09:00
joungmin
d73947444f feat(backend): #359 1단계 — google_place_id 중복 조회 API
- GET /api/admin/restaurants/duplicates/place-id (어드민 전용)
- 그룹별 식당 목록 + video/review/memo 카운트 동봉
- Mapper: findDuplicatePlaceIdRows + Service 그룹핑
- 정리/병합 + UNIQUE 제약은 데이터 위험 분리 위해 후속 PR로

Refs: #359 (조회 단계 완료)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 20:32:40 +09:00
joungmin
c1050f3abd feat(backend): #358 RestaurantUpdateDTO + @Valid 표준화
- dto/RestaurantUpdateDTO record 신규 (15 필드, 모두 nullable)
- @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 필드 자체가 화이트리스트)

Refs: #358 (close)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 20:20:51 +09:00
joungmin
a504bf8ee5 feat(backend): #357 DDG → Naver Search 정식 API + DDG 폴백
- WebSearchService 신규 (Naver webkr.json 우선, 키 미설정/실패 시 DDG)
- RestaurantController.searchTabling/searchCatchtable 내부 호출 교체
- 인라인 DDG 80줄 제거, 미사용 import 정리
- app.naver.client-id/secret 추가 (env: NAVER_CLIENT_ID/SECRET)
- k8s secrets template에 NAVER 키 항목

Refs: #357 (close)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 20:16:14 +09:00
joungmin
f1164b63c5 docs(design): #357 정식 검색 API 전환 설계서 (Architect)
- WebSearchService 신규: Naver Search webkr.json 우선, DDG 폴백
- searchTabling/searchCatchtable 내부 호출만 교체 (시그니처 유지)
- application.yml + k8s secrets에 NAVER_CLIENT_ID/SECRET 추가

Refs: #357

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 20:11:52 +09:00
joungmin
47020fd649 feat(backend): #356 영상-식당 관련도 LLM 평가
- DB V20260615b: video_restaurants.{relevance, relevance_reason, relevance_evaluated_at} + idx_vr_relevance
- VideoRelevanceService (#322 패턴): @Async verifyAsync + verify + verifyAll(batchSize)
- PipelineService.processExtract → linkVideoRestaurant 후 verifyAsync(linkId) 자동 트리거
- GET /api/restaurants/{id}/videos: 기본 strong/unknown만, ?include_weak=true 시 모두 + relevance/reason
- AdminVideoRelevanceController: GET pending / POST all / POST {id}/evaluate / PATCH {id}
- 캐시 키 strong|all 분리, LLM 실패 시 unknown 안전 기본값(표시 유지)

Refs: #356 (close)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 19:38:07 +09:00
joungmin
88bbf3ca25 docs(design): #356 영상-식당 관련도 LLM 평가 설계서 (Architect)
video_restaurants.relevance(strong/weak/incidental/unknown) 컬럼 + VideoRelevanceService.
findVideoLinks에 includeWeak 파라미터. 어드민 4개 API.
#322 식당 LLM 검증과 동일 패턴.

설계서: docs/design/356-video-relevance-llm/README.md (Approved)
Refs: #356 (Architect)
2026-06-15 19:24:19 +09:00
joungmin
8152b71119 docs(changelog): v0.1.42 #351 SSE 통일 기록 2026-06-15 17:17:14 +09:00
joungmin
d6ee62230e refactor(admin): #351 SSE 6곳 consumeSseStream으로 통일
VideosPanel:
- bulkTranscript/bulkExtract: 단일 SSE 핸들러 → consumeSseStream
- rebuildVectors: consumeSseStream
- remapCuisine / remapFoods: consumeSseStream

RestaurantsPanel:
- bulkTabling / bulkCatchtable: consumeSseStream

이전: 각 호출이 자체적으로 reader+decoder+buf.split+match 6곳 복제.
이제: lib/admin-utils.ts의 consumeSseStream(resp, onEvent)으로 일관 처리.

빌드 + npm test 13/13 통과. 회귀 없음.

Refs: #351
2026-06-15 17:15:35 +09:00
joungmin
cf1055bdf9 docs(changelog): v0.1.40 #343 테스트 인프라 기록 2026-06-15 16:29:22 +09:00
joungmin
2580414790 build(npm): #343 lock 재생성 (Jest 30 + @testing-library/* 동기화) 2026-06-15 16:26:52 +09:00
joungmin
730727a7a6 test(frontend): #343 Jest+RTL 인프라 + ARIA Tabs + remotePatterns
테스트 인프라:
- Jest 30 + jest-environment-jsdom + RTL + jest-dom matchers
- next/jest로 SWC/Next.js 자동 통합
- jest.config.ts (setupFilesAfterEnv) + jest.setup.ts
- npm scripts: test, test:watch
- 샘플 테스트 3개, 13/13 통과:
  - i18n/config: isLocale + detectBrowserLocale (5 케이스)
  - Stars 컴포넌트: 별점/aria/clamp/showNumber (5 케이스)
  - admin-utils: getAdminToken + authHeaders (4 케이스)

ARIA Tabs (MyReviewsList):
- role=tablist + tab + aria-selected + aria-controls + tabIndex
- panel에 role=tabpanel + aria-labelledby

next/image:
- next.config.ts remotePatterns: lh3.googleusercontent.com / i.ytimg.com / yt3.ggpht.com
- ReviewSection의 user_avatar_url에 명시적 eslint-disable + 사유

후속(별도): 전체 컴포넌트 테스트 점진 추가, 백엔드 JUnit 인프라, E2E (Playwright), CI 통합

설계서: docs/design/343-frontend-test-infra/README.md

Refs: #343
2026-06-15 16:25:55 +09:00
joungmin
9ba905aad8 docs(design): #343 RTL/Jest 인프라 + next/image + ARIA Tabs 설계서 (Architect)
next/jest + RTL 도입, 샘플 테스트 3개(Stars + i18n config), remotePatterns,
MyReviewsList Tabs ARIA. 백엔드 JUnit/E2E/CI는 후속.

설계서: docs/design/343-frontend-test-infra/README.md (Approved)
Refs: #343 (Architect)
2026-06-15 16:17:23 +09:00
joungmin
8c4b0c3e9a docs(changelog): v0.1.38 #348 isNameSimilar 한국어 기록 2026-06-15 16:12:46 +09:00
joungmin
3815221535 feat(util): #348 isNameSimilar 한국어 자모 + Sørensen-Dice
- HangulSimilarity 유틸 신규
  - decompose: Unicode NFD 분해 (한글 음절 → 초성/중성/종성)
  - 공백·구두점 제거 + 소문자화
  - bigram multiset 기반 Sørensen-Dice 계수
  - 빈 입력/포함 관계 가드
- RestaurantController.isNameSimilar 임계값 0.45 (이전 Jaccard 0.4와 유사 보수성)
- 기존 normalize 헬퍼 제거 (HangulSimilarity 내부로 이동)

DDG/DTO/UNIQUE는 별도 후속:
- 외부 검색 API 선정 (Naver/Kakao/Google CSE)
- RestaurantUpdateDTO + @Valid
- google_place_id 중복 정리 후 UNIQUE 제약

설계서: docs/design/348-name-similarity/README.md

Refs: #348 (Developer 단계)
2026-06-15 16:10:44 +09:00
joungmin
49ef0322ac docs(design): #348 isNameSimilar 자모 + Sørensen-Dice (Architect)
NFD 자모 분해 + bigram 기반 Sørensen-Dice 계수로 한국어 정확도 향상.
DDG/DTO/UNIQUE는 별도 후속(외부 API/큰 리팩터링/DB 데이터 정리 필요).

설계서: docs/design/348-name-similarity/README.md (Approved)
Refs: #348 (Architect)
2026-06-15 16:09:19 +09:00
joungmin
cc4bc0b7e4 docs(changelog): #352 i18n 뼈대 + v0.1.37 기록 2026-06-15 16:02:40 +09:00
joungmin
515f5c1d1a build(npm): #352 package-lock 재생성 (next-intl + @swc/helpers 동기화) 2026-06-15 15:59:44 +09:00
joungmin
6cbf7feaf5 feat(i18n): #352 다국어 뼈대 ko/en/ja/es
- next-intl 5.x 도입
- src/i18n/config.ts: LOCALES 상수, detectBrowserLocale, LOCALE_LABELS(국기/네이티브명)
- src/i18n/LocaleProvider.tsx: NextIntlClientProvider wrap + localStorage tasteby_locale 저장
- src/messages/{ko,en,ja,es}.json: 초기 30개 키 (header/actions/filter/restaurant/review 5 카테고리)
- src/components/LanguageSwitcher.tsx: 헤더용 드롭다운 (국기 + native, ARIA listbox, 44px 터치)
- providers.tsx: LocaleProvider로 AuthProvider 감싸기
- page.tsx 헤더에 LanguageSwitcher 배치

설계서: docs/design/352-i18n-skeleton/README.md (Approved)

언어 선택 근거:
- ko: 기본
- en: 글로벌 1순위
- ja: 일본 사용자 + 한국 음식 관광
- es: 5억 화자, 라틴아메리카 + 스페인 확장

미번역 키는 ko fallback. URL 라우팅(/en/)/SEO meta/사용자 콘텐츠 번역은 후속.

Refs: #352
2026-06-15 15:58:21 +09:00
joungmin
fda2d76514 docs(changelog): #329 어드민 분리 기록 2026-06-15 15:53:34 +09:00
joungmin
7d95ecb3cb refactor(admin): #329 admin/page.tsx 분리 + localStorage 통일
- page.tsx 2817 LOC → 107 LOC (탭 라우팅 + CacheFlushButton만)
- _panels/ChannelsPanel.tsx (222), VideosPanel.tsx (1282),
  RestaurantsPanel.tsx (675), UsersPanel.tsx (383), DaemonPanel.tsx (231)
- localStorage.getItem("tasteby_token") 10곳 → getAdminToken() (lib/admin-utils)
- 패널 내부 로직/state 그대로 (점진적 접근, 회귀 위험 최소화)

후속 분리:
- (신규) SSE 파싱 6곳을 consumeSseStream으로 통일

설계서: docs/design/329-admin-split/README.md

Refs: #329 (Developer)
2026-06-15 15:52:08 +09:00
joungmin
7b2753b9fd docs(design): #329 admin/page.tsx 분리 + 유틸 통일 설계서 (Architect)
5개 패널을 _panels/<Panel>.tsx로 추출 + localStorage→getAdminToken + SSE→consumeSseStream.
내부 로직 변경 없음(점진적 접근).

설계서: docs/design/329-admin-split/README.md (Approved)
Refs: #329 (Architect)
2026-06-15 15:48:49 +09:00
joungmin
7411c8956f docs(changelog): v0.1.34 #331 VectorService batchUpdate 기록 2026-06-15 15:42:43 +09:00
joungmin
be302612f5 perf(vector): #331 VectorService.saveRestaurantVectors batchUpdate
- N+1 단건 jdbc.update → 단일 jdbc.batchUpdate(SqlParameterSource[])
- UUID 인라인 변환 → IdGenerator.newId() 공통 유틸
- 현재 N=1로 영향 작으나, chunk 분할 도입 시를 위한 사전 정비
- 회귀 없음: 동일 INSERT SQL, 동일 파라미터 매핑

설계서: docs/design/331-vector-batch-insert/README.md

Refs: #331 (Developer 단계)
2026-06-15 15:40:45 +09:00
joungmin
91d9813253 docs(design): #331 VectorService batchUpdate 설계서 (Architect)
jdbc.batchUpdate(SqlParameterSource[]) 단일 호출 + IdGenerator.newId() 공통화.
테스트는 본 범위 밖 (#343 후속 테스트 인프라).

설계서: docs/design/331-vector-batch-insert/README.md (Approved)
Refs: #331 (Architect)
2026-06-15 15:39:50 +09:00
joungmin
11e1cf7877 docs(changelog): v0.1.33 #326 parseJson 단일 패스 기록 2026-06-15 15:37:48 +09:00
joungmin
648ccde4d7 perf(parse): #326 parseJson truncated-array O(N²) → 단일 패스
OciGenAiService.parseJson:
- 잘린 배열 복구 시 각 idx에서 end를 1씩 늘려 readValue 시도하던 O(N²) 로직 제거
- findObjectEnd: brace depth counter (문자열/escape 처리) 단일 패스 O(N)
- 8192 token 응답 처리 시간 수백 ms → 10ms 이하 예상
- 매 try마다 Jackson 예외 객체/스택트레이스 생성하던 부담 제거

설계서: docs/design/326-parsejson-optimization/README.md (Approved)

Refs: #326
2026-06-15 15:35:48 +09:00
joungmin
ed61d29632 docs(changelog): v0.1.32 #332 화이트리스트 기록 2026-06-15 15:33:52 +09:00
joungmin
51f7b5c7d3 feat(restaurant): #332 PUT body 화이트리스트 명시화
ALLOWED_UPDATE_FIELDS set으로 PUT /api/restaurants/{id} body를 SQL updateFields
컬럼 가드와 1:1로 매핑. 허용 외 키는 silent drop + DEBUG 로그.

기존 SQL <if containsKey>로 이미 임의 컬럼 갱신이 차단되어 있으나, Controller에
명시 화이트리스트가 없어 의도 모호. 본 변경으로 두 레이어 모두 화이트리스트 확보.

sanitized가 비면 200 no-op로 응답 (사용자 경험 우선).

DDG/isNameSimilar/DTO는 별도 후속 (예: #346) 분리.

설계서: docs/design/332-restaurant-update-whitelist/README.md

Refs: #332
2026-06-15 15:31:56 +09:00
joungmin
f4cb95e88c docs(design): #332 Restaurant 화이트리스트 설계서 (Architect)
ALLOWED_UPDATE_FIELDS set으로 PUT body 허용 키 명시. DDG/isNameSimilar/DTO는 후속.

설계서: docs/design/332-restaurant-update-whitelist/README.md (Approved)
Refs: #332 (Architect)
2026-06-15 15:30:38 +09:00
joungmin
109ad106ac docs(changelog): v0.1.31 #337 봇/레이트리밋 기록 2026-06-15 15:28:52 +09:00
joungmin
319fd18258 feat(stats): #337 봇 UA 필터 + IP 레이트리밋
- BotDetector 유틸 (Pattern.CASE_INSENSITIVE: bot|crawler|spider|slurp|scrap|fetch|monitor|preview|lighthouse)
- RateLimitService: Redis SET NX EX(60s) 패턴으로 같은 IP 윈도우 차단
  - Bucket4j 대신 spring-data-redis 기존 의존성 재사용 (간결)
  - Redis 다운 시 fail-open (사용자 경험 우선)
- StatsController.recordVisit: HttpServletRequest 받아 UA + X-Forwarded-For 우선 IP
  - 봇/리밋 초과 → 200 + counted:false (사용자 페이지 로드 지장 X)
  - 통과 → 200 + counted:true → statsService.recordVisit()
- application.yml: app.rate-limit.visit-window-seconds (env VISIT_WINDOW_SECONDS) 기본 60
- dev 검증: 봇 UA → counted=false, Mozilla → true, 즉시 재호출 → false

설계서: docs/design/337-stats-bot-ratelimit/README.md

Refs: #337 (Developer 단계)
2026-06-15 15:26:27 +09:00
joungmin
0fa58a622c docs(design): #337 통계 봇 필터 + 레이트리밋 설계서 (Architect)
User-Agent 봇 패턴 필터 + Bucket4j-redis IP 레이트리밋(1/min).
응답은 항상 200 + counted:bool로 사용자 페이지 로드 지장 X.

설계서: docs/design/337-stats-bot-ratelimit/README.md (Approved, 12개 섹션)

Refs: #337 (Architect 단계)
2026-06-15 15:23:12 +09:00
joungmin
9743f96af7 docs(changelog): v0.1.30 #335 ShedLock 분산 락 기록 2026-06-15 15:21:20 +09:00
joungmin
e5dc0534c4 feat(daemon): #335 분산 락 (ShedLock + Redis)
build.gradle:
- shedlock-spring 5.16.0
- shedlock-provider-redis-spring 5.16.0

TastebyApplication: @EnableSchedulerLock(defaultLockAtMostFor=PT15M)

ShedLockConfig 신규: RedisLockProvider Bean (in-cluster Redis 재사용)

DaemonScheduler.run:
- @SchedulerLock(name="daemon-runner", lockAtMostFor=PT15M, lockAtLeastFor=PT30S)
- 멀티 파드 환경(RollingUpdate 등)에서 한 인스턴스만 실행
- Redis 키: lock:daemon-runner

설계서: docs/design/335-daemon-distributed-lock/README.md (commit c88cb6a)

Refs: #335 (Developer 단계)
2026-06-15 15:18:14 +09:00
joungmin
c88cb6ad54 docs(design): #335 데몬 분산 락 설계서 (Architect)
ShedLock + Redis lock provider 선택. DaemonScheduler.run을
@SchedulerLock(name='daemon-runner', lockAtMostFor=PT15M, lockAtLeastFor=PT30S)
로 보호. RollingUpdate 시 두 파드 공존 중 YouTube/OCI 중복 호출 차단.

설계서: docs/design/335-daemon-distributed-lock/README.md (Approved, 12개 섹션)

Refs: #335 (Architect 단계)
2026-06-15 15:16:06 +09:00
joungmin
079384b645 docs(changelog): v0.1.29 #336 SCAN/UNLINK/복구/메트릭 기록 2026-06-15 15:09:57 +09:00
joungmin
c7bd3c4c09 feat(cache): #336 SCAN/UNLINK + disabled 자동 복구 + 에러 메트릭
- CacheService.flush: redis.keys() 블로킹 → SCAN cursor + UNLINK 논블로킹.
  UNLINK 미지원 환경은 DEL로 폴백. 500 batch 단위.
- 30초 주기 @Scheduled checkHealth: Redis ping → disabled 자동 토글.
  startup 시 disabled=true여도 Redis 재기동되면 자동 복구.
- recordError 헬퍼: AtomicLong errorCount + volatile lastError.
  로그 throttle (n==1 || n%100==0만 WARN, 나머지 DEBUG).
- CacheStats record + GET /api/admin/cache/stats (admin only).
- 설계서: docs/design/336-cache-scan-recovery/README.md (Approved).

Refs: #336
2026-06-15 15:07:22 +09:00
joungmin
1a5db34e15 fix(review): #334 ReviewService update/delete @Transactional 명시 (단일 SQL이지만 일관성) 2026-06-15 14:55:51 +09:00
joungmin
f126664117 docs(changelog): P5-2 작은 후속 기록 2026-06-15 14:51:22 +09:00
joungmin
a0e8878d9a feat: P5-2 작은 후속 (#338+#320+#340+#333)
#338: /api/version 신규
- HealthController에 @Value 빌드 정보 + GET /api/version 추가
- SecurityConfig.permitAll에 /api/version 추가
- application.yml app.build.version/commit (env APP_VERSION/APP_COMMIT)
- 부수: SecurityConfig에서 /api/daemon/config permitAll 제거 (이미 admin-only)

#320: findRegionFromCoords 거리 보정
- 유클리드 거리 → cos(lat) 가중치(equirectangular approx)로 위경도 실거리 보정
- 위도가 큰 지역(부산↔서울)에서 city 추정 정확도 향상

#340: MapView 마커/범례 ARIA
- 클러스터 마커: role=button + aria-label
- 개별 식당 마커: role=button + aria-label (name + 폐업 여부)
- 채널 범례: role=region + aria-label, 색상 점은 aria-hidden

#333: ChannelController 캐시 세분화
- cache.flush() 전체 무효화 → cache.del(makeKey("channels"))로 채널 키만 evict
- 다른 모듈(restaurants/search) 캐시 hit율 보존

후속: deploy.sh에 APP_VERSION/APP_COMMIT env 주입은 별도 (현재 dev/unknown 응답)

Refs: #338 #320 #340 #333
2026-06-15 14:48:32 +09:00
joungmin
3304b9c54f docs(changelog): v0.1.24 P5-1 작은 후속 기록 2026-06-15 14:44:08 +09:00
joungmin
437e709a8d feat: P5-1 작은 후속 묶음 (#319+#325+#344)
#325 (#291 후속):
- VideoSseController.bulkExtract: Math.random() → ThreadLocalRandom 통일
  (bulkTranscript와 일관)
- VideoSseController.rebuildVectors: 즉시 complete(total=0) 대신 명시적
  'not_implemented' SSE 이벤트로 운영자 가시성 확보 + timeout 600s → 60s
- YouTubeService.getTranscript JavaDoc: mode 인자가 youtube-transcript-api
  폴백에서만 사용된다는 점, 브라우저 추출은 mode 무관 명시

#319 (#301 후속):
- RestaurantDetail: buildSearchQuery 헬퍼 추출 (외부 지도 검색 URL 조합)
  '한국' 단독 region 더미 케이스 가드 포함
- BottomSheet SNAP_POINTS/VELOCITY_THRESHOLD 정책 fn-doc 신규
  (docs/design/279-frontend-restaurant-detail/fn-bottomsheet-snap.md)

#344 (#283 후속):
- globals.css에 --z-bottom-sheet=50, --z-filter-sheet=60, --z-modal=70 토큰
- LoginMenu: zIndex 99999 매직 넘버 → var(--z-modal)

Refs: #319 #325 #344
2026-06-15 14:40:45 +09:00
joungmin
dcebb9f06f docs(changelog): v0.1.23 P4-4 별점/로그인 결함 기록 2026-06-15 14:34:27 +09:00
joungmin
bff3dcc200 feat(ui): P4-4 별점 공통화 + 로그인 모달 접근성 (#281+#283)
#281 (리뷰/메모 UI):
- Stars 컴포넌트 신규 (lib 분리 가능한 공통 별점) — 0.5 단위 절반 채우기 시각 구분
- ReviewSection/MemoSection의 StarDisplay 제거 → 공통 Stars 사용 (시각 일관성)
- StarSelector: role='radiogroup'/role='radio' + aria-checked, 44×44px 터치 영역,
  반쪽 별 '⯨' 표시로 시각 차별화
- ReviewSection/MemoSection: API 실패 try/catch + alert 사용자 피드백
- MyReviewsList: Math.round 별점 → Stars 0.5단위 정확 렌더

#283 (로그인 메뉴):
- LoginMenu: useEscapeKey + useFocusTrap + useBodyScrollLock 적용
- role='dialog' / aria-modal / aria-labelledby / aria-label='로그인 창 닫기'
- onError 콘솔만 → 인라인 role='alert' 메시지로 사용자 피드백
- max-w-xs → max-w-sm (위젯 260px + 패딩 24px = 308px 안전 수용)

후속 분리:
- #343 (next/image + ARIA Tabs + Stars 테스트)
- #344 (z-index 토큰 + i18n)

Refs: #281 #283
2026-06-15 14:33:15 +09:00
joungmin
ea8db4bef3 docs(changelog): v0.1.22 P4-3 인증/지도 결함 기록 2026-06-15 14:29:10 +09:00
joungmin
ed076411ed fix: P4-3 인증 메시지 + 지도 cleanup/터치/접근성 (#266+#278)
#266 (인증):
- AuthService.loginGoogle: catch-all에서 e.getMessage() 노출 → "Invalid Google token"
  고정 메시지 + 상세는 log.warn (Google verifier 내부 오류 정보 누출 차단)

#278 (지도):
- boundsTimerRef 언마운트 cleanup (unmounted setState 경고 + 메모리 누수 방지)
- '내 위치' 버튼 36×36 → 44×44 + aria-label='내 위치로 이동' + touch-manipulation
- dead code 제거 (indexRef set-only, restaurantMap 미사용)

#277 (health) — 결함 모두 후속 분리 (deep health, version, 테스트, rate limit)

후속 분리:
- #338 (deep health/version/Actuator)
- #339 (hex → brand-* 토큰 + 마커 ARIA + 테스트)
- #340 (다중 audience verifier + AuthService 테스트)

Refs: #266 #277 #278
2026-06-15 14:25:53 +09:00
joungmin
865cd86aff docs(changelog): v0.1.21 데몬/캐시/통계 결함 기록 2026-06-15 14:22:13 +09:00
joungmin
c6428e5d5f fix(infra): P4-2 데몬/캐시/통계 결함 (#275+#276+#274)
#275 (데몬):
- DaemonConfigService.updateConfig: 정수 필드 가드 (비숫자/0/음수 → 400)
- DaemonScheduler: 외부 호출(scan/process) try-finally로 updateLastX 보장
  (예외 시에도 다음 cron까지 backoff)
- DaemonController.getConfig: AuthUtil.requireAdmin() 추가 (운영 설정 노출 차단)

#276 (캐시):
- CacheService 생성자: ping을 try-with-resources로 자원 누수 차단,
  ConnectionFactory null 가드
- makeKey: null/빈 parts 가드 (잘못된 키 생성 방지)

#274 (통계):
- SiteVisitStats: int → long (21억 누적 시 오버플로 방지)
- StatsMapper: getTodayVisits/getTotalVisits long 반환
- StatsService.recordVisit: 자정 경계 동시성 DataIntegrityViolationException
  1회 재시도, 2회 실패 시 1건 손실 수용 (운영 영향 미미)

후속 분리:
- #336 (#275 분산 락 + DTO + 테스트)
- #337 (#276 SCAN + 자동복구 + 메트릭)
- #338 (#274 봇/레이트리밋 + Redis INCR + 테스트)

Refs: #275 #276 #274
2026-06-15 14:20:14 +09:00
joungmin
5579c5b00f docs(changelog): v0.1.20 백엔드 CRUD 결함 기록 (#290+#294+#295) 2026-06-15 14:16:41 +09:00
joungmin
4b02293046 fix(crud): P4-1 백엔드 CRUD 결함 일괄 수정 (#290+#294+#295)
#294 (리뷰/메모):
- MemoService.upsert: 동시성 INSERT 시 DuplicateKeyException 폴백 → UPDATE
- ReviewService.toggleFavorite: 동시성 INSERT 시 DuplicateKeyException ignored (토글 ON)
- ReviewController: rating(0~5) Bean validation 헬퍼, body.rating null/비숫자 → 400
- ReviewMapper.xml getAvgRating: NVL로 0건 시에도 0.0 보장

#295 (채널):
- ChannelController.create: typed DataIntegrityViolationException으로 유니크 충돌 감지 (제약명 문자열 매칭 폐기)
- ChannelController.create: channel_id/channel_name null/빈값 → 400
- ChannelService.deactivate: "UC..." 형식 검증으로 명시적 분기 (이전 폴백 방식의 의도 모호함 해결)
- ChannelMapper.xml findByChannelId: description/tags/sort_order까지 SELECT

#290 (식당 CRUD):
- RestaurantController: @PreDestroy로 virtual thread executor shutdown
- RestaurantController: 캐시 역직렬화 실패를 silent ignore → log.warn + cache.del 자동 evict
- RestaurantController: setTablingUrl/setCatchtableUrl URL 스킴 화이트리스트 검증
- CacheService: 단일 키 del() 메서드 추가

후속 분리:
- #333 (#290 DTO 화이트리스트 + DDG 대체)
- #334 (#295 cache.flush 세분화 + scan 비동기)
- #335 (#294 테스트)

Refs: #290 #294 #295
2026-06-15 14:14:41 +09:00
joungmin
eb1eaa91a6 docs(changelog): v0.1.19 #293 검색/벡터 결함 기록 2026-06-15 14:04:09 +09:00
joungmin
9c2dc9f43a fix(search): #293 검색/벡터 결함 7건 일괄 수정
- SearchController: q 빈값 가드 (HTTP 400) — '%%' LIKE 응답 폭발 차단
- SearchService:
  - keywordSearch: LIKE 와일드카드 escape (%, _, \\)
  - hybrid 모드: semantic 결과에도 attachChannels 호출 (이전: keyword만)
  - ObjectMapper/TypeReference static 재사용 (캐시 hit 경로 GC 압박 완화)
  - 알 수 없는 mode → warn 로그 + keyword fallback (이전: silent)
  - maxDistance를 @Value("${app.search.max-distance:0.57}")로 외부화
- SearchMapper.xml: LIKE 절에 ESCAPE '\\' 추가
- VectorService.searchSimilar: embeddings/first list null/empty 가드 (NPE 방지)
- application.yml: app.search.max-distance (env SEARCH_MAX_DISTANCE) 추가

후속 분리: batch insert + 테스트 (별도 후속 이슈)

Refs: #293
2026-06-15 14:01:59 +09:00
joungmin
7779d5ddfd docs(changelog): v0.1.18 어드민 검증 UI 기록 (#304+#323) 2026-06-15 13:58:56 +09:00
joungmin
6ea82a5561 feat(admin): #304+#323 LLM 검증 UI + 공통 유틸 추출
#323 (LLM 검증 어드민 UI):
- api.ts: getVerifyPending / verifyAll / verifyOne / setRestaurantHidden 추가
- Restaurant 타입에 hidden / hidden_reason / verified_at 추가
- RestaurantsPanel 헤더에 "미검증 N건 + LLM 검증" 버튼 추가
- 테이블에 "검증" 컬럼 추가:
  - hidden=true → "숨김 (사유)" 버튼 (클릭 시 해제)
  - verified_at 있고 visible → "OK" 버튼 (클릭 시 숨김)
  - 미검증 → "미검증" 텍스트

#304 (어드민 공통 유틸):
- lib/admin-utils.ts 신규
  - getAdminToken(): localStorage 직접 접근 통일
  - authHeaders(): 표준 Bearer 헤더
  - consumeSseStream(): SSE 라인 파싱 헬퍼
- colSpan 6 → 7로 검증 컬럼 반영

후속 분리: #329 (admin/page.tsx 전체 분리 + localStorage/SSE 호출 11+곳 교체)

Refs: #304 #323 #322
2026-06-15 13:57:33 +09:00
joungmin
04c54d1b1a docs(changelog): v0.1.17 백엔드 결함 일괄 수정 기록 (#291+#292) 2026-06-15 13:23:51 +09:00
joungmin
4407f2d67d fix(pipeline): #291+#292 운영 영향 큰 결함 6건 일괄 수정
#292:
- ExtractorService.extractRestaurants: transcript null/blank 가드 (NPE 방지)
- PipelineService.processExtract: 진입 시 status='processing' 명시 전이
- PipelineService.processExtract: geocode 실패(geo==null) 시 좌표/place_id/주소
  관련 컬럼을 data에 put하지 않아 upsert COALESCE 보존 의도 명확화
- GeocodingService.parseRegionFromAddress: 빈 토큰을 region 문자열에 포함하지
  않도록 정규화 (예: '한국||구' 같은 깨진 토큰 방지)

#291:
- VideoService.saveVideosBatch: @Transactional 추가 → batch insert 원자성
- .gitignore: backend-java/cookies.txt 및 **/cookies.txt 명시 (보안 노출 차단)

후속 분리: #325 (#291 잔여 MINOR), #326 (#292 parseJson 최적화 + MINOR)

Refs: #291 #292
2026-06-15 13:21:25 +09:00
joungmin
7fa623d22d docs: CHANGELOG v0.1.15+v0.1.16 기록 + #322 설계서 Approved 2026-06-15 13:07:08 +09:00
joungmin
d2e78b0363 feat(verify): #322 LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김
DB 마이그레이션 (운영 ATP에 사전 실행 완료):
- restaurants.hidden NUMBER(1) DEFAULT 0 NOT NULL
- restaurants.hidden_reason VARCHAR2(120)
- restaurants.verified_at TIMESTAMP
- idx_restaurants_hidden 인덱스

코드:
- Restaurant 도메인에 hidden/hiddenReason/verifiedAt 필드 추가
- RestaurantMapper.xml resultMap 갱신 + findAll에 hidden=0 조건 (includeHidden=true 시 제외)
- RestaurantMapper에 updateVerification/clearHidden/findUnverified/countUnverified 추가
- RestaurantService.findAll() includeHidden 오버로드 + 검증 헬퍼 메서드
- RestaurantVerifyService 신규 (verify, verifyAsync, verifyAll, buildPrompt, parseVerifyResponse)
  - LLM 응답이 JSON 아닐 때 안전 기본값(valid=true) → hidden 유지
  - 백필은 식당당 200ms sleep으로 LLM rate 보호
- 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 {hidden, reason}

프롬프트:
- 식당명, 주소, 지역, cuisine, foods를 OCI GenAI로 보내 valid/is_franchise/reason 판정
- 보수적 가이드 (모호하면 valid=true)

설계서: docs/design/322-restaurant-llm-verify/README.md (Approved 대기)

Refs: #322
2026-06-15 13:04:23 +09:00
joungmin
d3cd1b5d5f feat(daemon): instance-level enable flag (dev/prod 중복 폴링 방지)
dev와 prod가 같은 Oracle ATP 인스턴스(_low vs _medium tier만 다름)를 공유하는
환경에서 dev/prod 양쪽 DaemonScheduler가 같은 daemon_config row를 폴링하면
같은 시점에 동일 채널 스캔이 발생 → YouTube 봇 감지 위험 증가.

수정:
- application.yml: app.daemon.enabled (env DAEMON_ENABLED, 기본 true)
- DaemonScheduler.run() 첫 줄에서 인스턴스 플래그 검사 후 차단
- dev/backend/.env에 DAEMON_ENABLED=false 설정 (이 커밋엔 미포함, 로컬만)

운영(OKE)은 env 미설정 → 기본 true로 정상 동작.
dev(PM2)는 .env로 false → 스케줄러 자체가 동작 안 함.

Refs: #275 #321
2026-06-15 12:50:41 +09:00
joungmin
51dcacc728 fix(scan): #291 YouTubeService.fetchChannelVideos publishedAfter 조기 종료 버그
업로드 재생목록(uploads playlist) 스캔에서 publishedAfter 이전 영상을 만나
break해도, do-while 조건이 응답의 nextPageToken을 보고 paging을 지속하던 결함.

수정:
- stopPaging boolean 플래그 추가
- inner-loop 조기 break에서 stopPaging = true
- outer paging 갱신 시 stopPaging 검사 우선

영향:
- 백필 효율 향상 (불필요한 API quota 소모 방지)
- 봇 감지 회피 (과한 페이징 요청 안 함)
- daemon 자동 모드의 안정적 동작 기반

Refs: #291 #321
2026-06-15 12:41:35 +09:00
joungmin
dc8a8e9b4c docs(changelog): #301+#302 모달 접근성 + race condition + 필터 동기화 (v0.1.14) 2026-06-15 12:25:12 +09:00
joungmin
43fd931824 fix(a11y): #301+#302 모달 접근성 + race condition + 필터 상태 동기화
CRITICAL — 모달 접근성:
- frontend/src/lib/hooks/useModalA11y.ts 신규 (useEscapeKey, useFocusTrap, useBodyScrollLock)
- BottomSheet: role='dialog' / aria-modal / aria-label / ESC 닫기 / focus trap
- FilterSheet: role='dialog' / aria-modal / aria-labelledby / ESC 닫기 / focus trap, 닫기 버튼 aria-label

MAJOR — race condition (#301):
- RestaurantDetail useEffect에 cancelled 플래그 추가 → restaurant.id 변경 시 이전 fetch 결과 폐기

MAJOR — 필터 상태 동기화 (#302):
- page.tsx에 exitSearchMode 헬퍼 추가
- 검색 모드(isSearchResult=true)에서 cuisine/price/country/city/district 변경 시 자동으로 검색 모드 해제 + 원본 restaurants 재로드

후속 분리: #319(BottomSheet 매직넘버/UX), #320(필터 정밀도/접근성/테스트)

Refs: #301 #302
2026-06-15 12:23:15 +09:00
joungmin
2d41f22b83 fix(infra): #316 backend resource request 재산정 + RollingUpdate 25%/25% 복귀
노드 다운사이징(2×1OCPU/6GB) 이후 backend CPU request 500m이 노드 한도
의 절반을 차지해 rollingUpdate 데드락 발생. 임시 패치(maxSurge=0/
maxUnavailable=1) 상태를 합리화하여 25%/25% 기본 정책으로 복귀.

변경:
- cpu 500m/1 → 300m/800m
- mem 768Mi/1536Mi → 512Mi/1024Mi
- strategy 25%/25% 명시 (기본값 복귀)

근거: 실측 idle CPU 0.7%, RSS ~305 MB. peak 30-40% 추정 안에서 안전.
검증: 적용 후 노드 잔여 330m → 다음 배포 시 두 Pod 공존 가능 (무중단).
다운타임: 이번 1회 ~25초 (구 500m Pod 점유 해제), 다음 배포부터 0초.

설계서: docs/design/316-backend-resource-rightsize/README.md (Approved).

Refs: #316
2026-06-15 12:07:47 +09:00
joungmin
2a6d307260 docs(changelog): OKE 다운사이징 + Orphan LB 삭제 + v0.1.13 배포 기록 2026-06-15 11:55:16 +09:00
joungmin
4638f605aa fix(security): [Developer] #267 AdminUserController GET 4종에 requireAdmin() 추가
CRITICAL: listUsers, userFavorites, userReviews, userMemos 4개 GET이 인증만 요구하고 admin 검사가 없어, 일반 사용자 토큰으로 전체 사용자 목록 및 타인 활동 조회 가능했음. 각 메서드 첫 줄에 AuthUtil.requireAdmin() 호출 추가 → non-admin은 403.

함께 커밋(이전 미커밋 작업):
- Logger 등록 (감사 로그용)
- AuthUtil/Logger/HttpStatus/ResponseStatusException import 정리
- updateAdmin: 자기 자신 admin 변경 차단 + 감사 로그
  (이미 동작 중이던 변경이나 git 미커밋 상태였음)

문서:
- 설계서 §3 인수조건에 권한 강제 항목 추가, 상태 Draft → Approved
- CHANGELOG.md 2026-06-15 핫픽스 항목 추가

검증:
- Anonymous GET /api/admin/users → 403 ✓
- Bad-token GET /api/admin/users → 403 ✓
- 백엔드 빌드 성공, tasteby-api 재시작 완료

Refs: #267
2026-06-15 11:17:48 +09:00
joungmin
80b553ec19 docs: 현행화 17개 설계서 Approved + 후속 이슈 백로그 등록
Reviewer 결과 17 PASS / 1 REJECT (#267 admin 권한 critical).
- 17개 설계서를 Draft → Approved로 갱신
- #267(backend-user)은 critical 결함으로 06-Reviewer 유지
- 후속 17개 개선 이슈(#289~#305) 자동 등록 — 결함 124건 백로그 반영
  (critical 3 / major 46 / minor 75)
- docs/README.md에 18개 설계서 인덱스 추가
- CHANGELOG.md 2026-06-15 섹션 추가

Refs: #266 #268-#283 (현행화 완료) #267 (대기) #289-#305 (백로그)
2026-06-15 11:08:18 +09:00
joungmin
e97a36a8d9 docs/design: tasteby 18개 기능 현행화 설계서 추가
- 백엔드 12개: auth/user/restaurant/video/extract-pipeline/search/review-memo/channel/stats/daemon/cache/health
- 프론트 6개: map/restaurant-detail/filter/review-memo/admin/login
- 12개 섹션 전 항목 채움 (목적/범위/인수조건/제약/아키텍처/데이터모델/함수명세표/흐름/엣지/테스트/리스크/미해결)
- 추적성 헤더에 구현 파일 경로 명시, 테스트는 TBD (현재 없음)
- 코드 변경 없음 — 기존 구현의 설계 문서화

Refs: #266 #267 #268 #269 #270 #271 #272 #273 #274 #275 #276 #277 #278 #279 #280 #281 #282 #283
2026-06-15 10:48:50 +09:00
joungmin
c78f928a2d ch-bootstrap: persona pipeline + Design-First + 안전-최대 권한
- Redmine 8단계 페르소나 파이프라인 (.claude/agents, workflows)
- Design-First docs 골격 (docs/design, docs/adr, docs/pipeline)
- 안전-최대 권한 정책 (.claude/settings.json)
- Tasteby 고유 규칙 보존 (CLAUDE.md 병합)
- scripts/enqueue.sh: Redmine 큐 투입

Refs: tasteby bootstrap
2026-06-15 10:20:50 +09:00
joungmin
f2861b6b79 홈 탭 장르 카드 UI + Tabler Icons 적용 + 지역 필터 추가
- 홈 탭: 장르 가로 스크롤 카드 (Tabler Icons 픽토그램)
- 홈 탭: 가격/지역/내위치 필터 2줄 배치
- 리스트 탭: 기존 바텀시트 필터 UI 유지
- cuisine-icons: Tabler 아이콘 매핑 추가 (getTablerCuisineIcon)
- 드래그 스크롤 장르 카드에 적용
- 배포 가이드 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:52:42 +09:00
joungmin
dda0da52c4 내위치 필터 모바일 리스트 적용 + 반경 4km
- mapBounds 없을 때 userLoc 기준 ~4km 반경 필터링
- 내위치 ON 시 setUserLoc도 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:25:36 +09:00
joungmin
18776b9b4b 바텀시트 필터 글씨 크기 미세 조정
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:15:59 +09:00
joungmin
177532e6e7 모바일 필터 바텀시트 UI 적용
- FilterSheet 컴포넌트 신규: 바텀시트로 올라오는 필터 선택 UI
- 장르/가격/지역 필터 모두 네이티브 select 대신 바텀시트 사용
- 카테고리별 그룹핑 + sticky 헤더 + 선택 체크 표시
- slide-up 애니메이션 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:13:46 +09:00
joungmin
64d58cb553 모바일 필터 UI pill 스타일로 개선
- select를 둥근 칩(pill) 형태로 변경 (아이콘 + 드롭다운 화살표)
- 선택 시 브랜드 컬러 배경 + 링 하이라이트
- 장르/가격/지역 필터 모두 동일 스타일 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:08:42 +09:00
joungmin
a766a74f20 모바일 리스트 레이아웃 개선 + 내위치 줌 조정
- 식당명/지역/별점 1줄, 종류+가격(왼)+유튜브채널(우) 2줄, 태그 3줄 배치
- 가격대: 종류가 공간 우선 차지, 나머지에서 truncate
- 내위치 줌 16→17로 조정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:04:32 +09:00
joungmin
4b1f7c13b7 Playwright 제거 → DuckDuckGo HTML 검색 전환 + UI 미세 조정
- 테이블링/캐치테이블 검색: Google+Playwright → DuckDuckGo HTML 파싱 (브라우저 불필요)
- 검색 딜레이 5~15초 → 2~5초로 단축
- 프론트엔드: 정보 텍스트 계층 개선 (폰트 크기/색상 조정)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:28:49 +09:00
joungmin
75e0066dbe 지도 마커 클러스터링 적용 (supercluster)
- 줌 16 이하에서 근접 마커를 숫자 원형 클러스터로 묶음
- 클러스터 클릭 시 해당 영역으로 자동 줌인
- 개별 마커 스타일/채널 색상 유지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:16:31 +09:00
joungmin
3134994817 비공개 메모 기능 추가 + 아이콘 개선
- 식당별 1:1 비공개 메모 CRUD (user_memos 테이블)
- 내 기록에 리뷰/메모 탭 분리
- 백오피스 유저 관리에 메모 수/상세 표시
- 리뷰/메모 작성 시 현재 날짜 기본값
- 지도우선/목록우선 버튼 Material Symbols 아이콘 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:10:06 +09:00
joungmin
88c1b4243e 로고 라이트 고정 + color-scheme only light 강화
- 다크/라이트 로고 전환 로직 제거, 라이트 로고 고정
- color-scheme: only light !important 강화
- supported-color-schemes 메타 태그 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:17:33 +09:00
joungmin
824c171158 Material Symbols 아이콘 전환 + 로고 이미지 적용 + 테이블링 이름 유사도 체크
- 전체 인라인 SVG를 Google Material Symbols Rounded로 교체
- Icon 컴포넌트 추가, cuisine-icons 매핑 리팩토링
- Tasteby 핀 로고 이미지 적용 (라이트/다크 버전)
- 테이블링/캐치테이블 이름 유사도 체크 및 리셋 API 추가
- 어드민 페이지 리셋 버튼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:55:04 +09:00
joungmin
4f8b4f435e 라이트 테마 강제 적용 + surface 토큰 + 텍스트 대비 개선
- dark: 클래스를 class 기반으로 전환 (다크모드 비활성화)
- color-scheme: light 강제
- surface 색상 토큰 추가 (카드/패널용)
- 리스트/사이드바 배경 bg-surface로 통일
- 텍스트 대비 강화 (gray-400 → gray-500/700)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:30:49 +09:00
joungmin
50018c17fa Saffron 디자인 시스템 적용: 브랜드 컬러 + Pretendard 폰트 + 크림 배경
- CSS 변수 기반 brand-50~950 컬러 팔레트 추가 (Tailwind @theme inline)
- Pretendard Variable 폰트 로드 및 기본 폰트로 설정
- 라이트모드 배경 #FFFAF5 크림색 적용 (다크모드 기본 유지)
- 전체 컴포넌트 orange-* → brand-* 마이그레이션
- 식당 리스트 채널명에 YouTube SVG 아이콘 추가
- 디자인 컨셉 문서 추가 (docs/design-concepts.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:15:45 +09:00
joungmin
ec8330a978 모바일 UI 개선: 내주변 지도전용, 필터 상시노출, 채널필터 전탭 확장
- 내주변 탭: 지도만 전체 표시 (리스트 제거), 마커 클릭 시 바텀시트 상세보기 유지
- 유튜버 채널 필터: 홈/식당목록/내주변 탭 모두에서 표시
- 모바일 필터: 토글 패널 → 항상 보이는 2줄 레이아웃 (장르+가격 / 나라+내위치)
- 모바일 헤더에 찜/리뷰 버튼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:49:42 +09:00
joungmin
e85e135c8b 검색/필터 UI 개선, 채널 정렬, 드래그 스크롤, 지도링크 수정
- 검색바: 아이콘 내장, 모드 select 제거 (hybrid 고정), 엔터 검색
- 필터 그룹화: [음식 장르·가격] [지역 나라·시·구] + X 해제 버튼
- 채널 필터: 드롭다운 → 유튜브 아이콘 토글 카드, 드래그 스크롤
- 채널 정렬: sort_order 컬럼 추가, 백오피스 순서 편집
- 채널+필터 동시 적용: API 호출 대신 클라이언트 필터링
- 내위치 ON 시 다른 필터 초기화, 역방향도 동일
- 전체보기 버튼: 모든 필터 일괄 해제
- 네이버맵: 한국 식당만, 식당명만 검색
- 구글맵: 식당명+주소/지역 검색
- 로그인 영역 데스크톱 Row 1 우측 배치
- scrollbar-hide CSS 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:42:25 +09:00
193 changed files with 24009 additions and 4340 deletions

View File

@@ -0,0 +1,32 @@
---
name: architect
description: "[AI] Architect — 구현 전 함수 단위 설계서 + ADR 작성, 기술 설계. 설계서 게이트의 작성자. 파이프라인 2단계."
tools: Bash, Read, Edit, Write, Grep, Glob
model: opus
---
너는 tasteby 파이프라인의 **[AI] Architect** 이며 **Design-First 게이트의 작성자**다.
시작 전에 반드시 읽는다: `CLAUDE.md`(특히 §2 설계서 우선, §3 문서 아키텍처),
`docs/README.md`, `docs/pipeline/QUEUE-PROTOCOL.md`, 이슈의 `## [AI] Planner` 섹션.
## 역할
- Planner 의 인수조건을 만족하는 **기술 설계**를 한다.
- I/O 와 순수 전략 로직의 **경계**를 명확히 설계한다(테스트 가능성 확보).
- 실제 구현 코드는 작성하지 않는다 — 빈 모듈/인터페이스 스텁까지만 허용.
## 필수 산출물 — 설계서 (이게 핵심, 없으면 다음 단계 진행 불가)
1. **기능 설계서**: `docs/design/<issue-id>-<slug>/README.md`
- `docs/design/_TEMPLATE.md` 를 복사해 모든 섹션을 채운다(빈 섹션 금지).
- **§7 함수 명세 표에 이 기능의 모든 함수를 등재**한다(시그니처·입출력·에러·복잡도).
2. **함수 설계서**: 복잡한 함수마다 `docs/design/<issue-id>-<slug>/fn-<name>.md`
- `docs/design/_FN_TEMPLATE.md` 사용. 복잡 기준은 CLAUDE.md §2.
- 단순 함수(게터·포매터 등)는 기능 설계서 표 한 줄로 충분.
3. **ADR**: 되돌리기 어려운 결정은 `docs/adr/NNNN-<title>.md`(`_TEMPLATE.md`)로 분리.
4. 이슈 `## [AI] Architect` 섹션에 설계 요약 + 설계서 경로 링크.
## 핸드오프 (게이트)
- **모든 함수가 설계서로 덮였는지 자가 점검**한 뒤에만 넘긴다. 누락 시 넘기지 않는다.
- 설계서 파일 git 커밋·push (`[Architect] #<ID> design spec`).
- 끝나면 카테고리 `03-Developer`, 상태 신규 로 전진. 프로토콜 (a),(b),(c) 준수.
- 저널 노트에 작성한 설계서/ADR 경로 목록을 남긴다.

View File

@@ -0,0 +1,48 @@
---
name: code-reviewer
description: |
Use this agent when a major project step has been completed and needs to be reviewed against the original plan and coding standards. Examples: <example>Context: The user is creating a code-review agent that should be called after a logical chunk of code is written. user: "I've finished implementing the user authentication system as outlined in step 3 of our plan" assistant: "Great work! Now let me use the code-reviewer agent to review the implementation against our plan and coding standards" <commentary>Since a major project step has been completed, use the code-reviewer agent to validate the work against the plan and identify any issues.</commentary></example> <example>Context: User has completed a significant feature implementation. user: "The API endpoints for the task management system are now complete - that covers step 2 from our architecture document" assistant: "Excellent! Let me have the code-reviewer agent examine this implementation to ensure it aligns with our plan and follows best practices" <commentary>A numbered step from the planning document has been completed, so the code-reviewer agent should review the work.</commentary></example>
model: inherit
---
You are a Senior Code Reviewer with expertise in software architecture, design patterns, and best practices. Your role is to review completed project steps against original plans and ensure code quality standards are met.
When reviewing completed work, you will:
1. **Plan Alignment Analysis**:
- Compare the implementation against the original planning document or step description
- Identify any deviations from the planned approach, architecture, or requirements
- Assess whether deviations are justified improvements or problematic departures
- Verify that all planned functionality has been implemented
2. **Code Quality Assessment**:
- Review code for adherence to established patterns and conventions
- Check for proper error handling, type safety, and defensive programming
- Evaluate code organization, naming conventions, and maintainability
- Assess test coverage and quality of test implementations
- Look for potential security vulnerabilities or performance issues
3. **Architecture and Design Review**:
- Ensure the implementation follows SOLID principles and established architectural patterns
- Check for proper separation of concerns and loose coupling
- Verify that the code integrates well with existing systems
- Assess scalability and extensibility considerations
4. **Documentation and Standards**:
- Verify that code includes appropriate comments and documentation
- Check that file headers, function documentation, and inline comments are present and accurate
- Ensure adherence to project-specific coding standards and conventions
5. **Issue Identification and Recommendations**:
- Clearly categorize issues as: Critical (must fix), Important (should fix), or Suggestions (nice to have)
- For each issue, provide specific examples and actionable recommendations
- When you identify plan deviations, explain whether they're problematic or beneficial
- Suggest specific improvements with code examples when helpful
6. **Communication Protocol**:
- If you find significant deviations from the plan, ask the coding agent to review and confirm the changes
- If you identify issues with the original plan itself, recommend plan updates
- For implementation problems, provide clear guidance on fixes needed
- Always acknowledge what was done well before highlighting issues
Your output should be structured, actionable, and focused on helping maintain high code quality while ensuring project goals are met. Be thorough but concise, and always provide constructive feedback that helps improve both the current implementation and future development practices.

View File

@@ -0,0 +1,25 @@
---
name: designer
description: "[AI] Designer — 사용자 접점(CLI 출력, 알림/로그 포맷, UX) 다듬기. 파이프라인 5단계."
tools: Bash, Read, Edit, Write, Grep, Glob
model: sonnet
---
너는 tasteby 파이프라인의 **[AI] Designer** 다.
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`.
## 역할
- tasteby 은 CLI/봇 중심이므로 **사용자 접점의 명료성**을 책임진다:
- 콘솔/로그 출력 포맷, 알림(예: Discord/Telegram) 메시지 문구
- 명령행 인자·설정 파일의 직관성, 에러 메시지의 친절함
- (UI 가 있다면) 화면/상호작용 흐름
- 메시지는 **짧고 실행 가능**하게. 돈·주문 관련 알림은 오해 없이 명확하게.
- 기능 동작은 바꾸지 않는다 — 표현·접점만 다듬는다.
## 산출물
- 출력/알림 포맷 개선 코드 또는 템플릿, 필요 시 `docs/design/ux-<issue-id>.md`.
## 핸드오프
- 변경 시 git 커밋·push (`[Designer] #<ID> ...`).
- 끝나면 카테고리 `06-Reviewer`, 상태 신규 로 전진. 프로토콜 (a),(b),(c) 준수.

View File

@@ -0,0 +1,31 @@
---
name: developer
description: "[AI] Developer — 설계서대로만 코드/테스트 구현. 설계서 없으면 구현 거부·반려. 파이프라인 3단계 (반려 복귀 지점)."
tools: Bash, Read, Edit, Write, Grep, Glob
model: opus
---
너는 tasteby 파이프라인의 **[AI] Developer** 다.
시작 전에 반드시 읽는다: `CLAUDE.md`(특히 §2 설계서 우선), `docs/README.md`,
`docs/pipeline/QUEUE-PROTOCOL.md`, 그리고 **이 이슈의 설계서**
(`docs/design/<issue-id>-<slug>/README.md` 와 관련 `fn-*.md`).
**반려되어 돌아온 경우** 최신 저널 노트의 QA/Reviewer **반려 사유**부터 읽고 고친다.
## ⛔ Design-First 사전 점검 (코드 작성 전 필수)
- 구현하려는 **모든 함수가 설계서로 덮여 있는지** 확인한다(표 등재 + 복잡 함수는 fn 파일).
- 설계서가 **없거나 불충분**하면 코드를 쓰지 말고 **즉시 반려**한다:
- 카테고리 `02-Architect`, 상태 신규, 노트에 "설계서 없음/불충분: <무엇이 빠졌는지>".
- outcome=rejected 로 보고.
## 역할 (설계서가 충분할 때만)
- **설계서대로** 코드를 구현한다. 설계서에 없는 동작을 임의 추가하지 않는다.
- 핵심 전략·리스크 로직에는 **단위 테스트**를 함께 작성(테스트 없이 머지 금지).
- CLAUDE.md 원칙(단일 책임, I/O 분리, 명시적 에러, 안전한 기본값) 준수. 비밀은 `.env` 주입.
- 설계와 달라져야 하면 **코드가 아니라 설계서를 먼저 고친다**(필요 시 Architect 반려).
- 구현한 공개 함수는 `docs/reference/` 에 사양을 동기화한다.
## 핸드오프
- 로컬에서 최소 한 번 실행/컴파일·테스트 확인. 변경을 의미 단위 커밋·push.
- 끝나면 카테고리 `04-QA`, 상태 신규 로 전진. 프로토콜 (a),(b),(c) 준수.
- 커밋: `[Developer] #<ID> <요약>`.

View File

@@ -0,0 +1,26 @@
---
name: documenter
description: "[AI] Documenter — README/문서/CHANGELOG 갱신, 이슈 최종 정리 후 종료(완료). 파이프라인 8단계."
tools: Bash, Read, Edit, Write, Grep, Glob
model: sonnet
---
너는 tasteby 파이프라인의 **[AI] Documenter** 이며 **마지막 단계**다.
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`,
그리고 이슈의 모든 `## [AI] *` 섹션(전체 흐름 파악).
## 역할
- 이번 변경을 사용자/운영 관점에서 문서화 (Diátaxis, `docs/README.md` 구조 준수):
- `README.md` 사용법·설정 절차 갱신.
- `docs/guides/` 사용 가이드(getting-started/how-to), `docs/reference/` 코드 사양 동기화.
- `CHANGELOG.md` 가 Release 단계에서 누락됐다면 보완.
- **설계서 마감**: `docs/design/<issue-id>-<slug>/` 의 설계서 상태를 `Approved` 로 갱신하고
추적성 헤더(구현 파일·테스트 경로)를 실제 경로로 채운다. 구현과 어긋난 곳이 있으면 동기화.
- 이슈 description 의 역할 섹션을 최종 핸드오프 상태로 정리하고, **작업 디렉토리**
(`/Users/joungmin/workspaces/tasteby`)를 명시한다.
## 종료
- 문서 변경 git 커밋·push (`[Documenter] #<ID> ...`).
- 프로토콜 §6 에 따라 카테고리 `09-Done`, 상태 **완료(5)**, done_ratio 100 으로 닫는다.
- 마지막 저널 노트에 전체 요약(무엇을·왜·어떻게 검증)을 남긴다.

26
.claude/agents/planner.md Normal file
View File

@@ -0,0 +1,26 @@
---
name: planner
description: "[AI] Planner — 기능 요구를 인수조건이 있는 실행 가능한 작업으로 분해. 파이프라인 1단계."
tools: Bash, Read, Edit, Write, Grep, Glob
model: opus
---
너는 tasteby 파이프라인의 **[AI] Planner** 다.
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`.
## 역할
- 이슈의 기능 요구를 명확한 **범위(scope)****인수조건(acceptance criteria)** 으로 정리.
- 너무 크면 하위 작업으로 쪼갠다(필요 시 Redmine 자식 이슈 생성).
- 무엇을 만들지 결정하되, **어떻게**(설계)·**코드**는 다음 페르소나에게 맡긴다.
## 산출물 (이슈 description 의 `## [AI] Planner` 섹션에 기록)
- 목표 1줄
- 인수조건 체크리스트 (검증 가능한 항목)
- 범위 밖(out of scope) 명시
- 리스크/가정
## 핸드오프
- 코드 변경이 없으면 git 커밋은 생략 가능하나, 이슈 description 갱신은 필수.
- 끝나면 카테고리를 `02-Architect`, 상태 신규 로 전진.
- 프로토콜의 "결과 남기기 (b),(c)" 를 따른다.

28
.claude/agents/qa.md Normal file
View File

@@ -0,0 +1,28 @@
---
name: qa
description: "[AI] QA — 테스트 작성/실행, 인수조건 검증. 통과 시 Designer, 실패 시 Developer 반려. 파이프라인 4단계 게이트."
tools: Bash, Read, Edit, Write, Grep, Glob
model: sonnet
---
너는 tasteby 파이프라인의 **[AI] QA** 이며 **품질 게이트**다.
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`,
이슈의 `## [AI] Planner` 인수조건.
## 역할
- Planner 의 **인수조건을 하나씩 검증**한다.
- **설계서 일치 검증**: 구현이 `docs/design/<issue-id>-<slug>/` 의 함수 명세(시그니처·
입출력·에러·엣지)와 일치하는지, 설계서의 테스트 케이스가 실제로 존재·통과하는지 확인.
- 테스트를 실행하고, 누락된 경계/회귀 테스트는 추가한다.
- 거래소 API 등 외부 의존은 가능한 한 모킹/드라이런으로 검증.
- 결과는 **PASS/FAIL** 로 명확히 판정한다. 애매하면 FAIL.
## 게이트 결정 (둘 중 하나)
- **PASS**: 모든 인수조건 충족 + 테스트 통과 → 카테고리 `05-Designer`, 상태 신규.
- **FAIL**: 하나라도 불충족 → 카테고리 `03-Developer`, 상태 신규 로 **반려**,
저널 노트에 **재현 절차 + 실패 항목 + 기대값/실제값**을 구체적으로 남긴다.
## 핸드오프
- 테스트 파일을 추가했으면 git 커밋·push (`[QA] #<ID> ...`).
- 프로토콜의 (a),(b),(c) 또는 §5(반려) 를 따른다.

25
.claude/agents/release.md Normal file
View File

@@ -0,0 +1,25 @@
---
name: release
description: "[AI] Release — 버전 태그, 빌드/배포 산출물, 릴리스 노트, git 태그 push. 파이프라인 7단계."
tools: Bash, Read, Edit, Write, Grep, Glob
model: sonnet
---
너는 tasteby 파이프라인의 **[AI] Release** 다.
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`.
## 역할
- 머지 가능한 상태를 **릴리스**로 묶는다:
- 필요 시 `feature/*``main` 병합, 빌드/패키징 실행·확인.
- 시맨틱 버전 결정 후 **git 태그** 생성 + Gitea push (`vX.Y.Z`).
- `CHANGELOG.md` 에 이번 변경 항목 추가.
- 실행/배포 절차(필요 런타임 파일, 시작 커맨드)를 이슈에 명시.
- 실거래 영향이 있는 변경은 배포 절차에 **안전장치/롤백**을 적는다.
## 산출물
- git 태그, CHANGELOG 항목, 릴리스 노트(이슈 `## [AI] Release` 섹션).
## 핸드오프
- 커밋·태그 push (`[Release] #<ID> ...`).
- 끝나면 카테고리 `08-Documenter`, 상태 신규 로 전진. 프로토콜 (a),(b),(c) 준수.

View File

@@ -0,0 +1,28 @@
---
name: reviewer
description: "[AI] Reviewer — 정확성·보안·표준 코드리뷰. 승인 시 Release, 위반 시 Developer 반려. 파이프라인 6단계 게이트."
tools: Bash, Read, Edit, Write, Grep, Glob
model: opus
---
너는 tasteby 파이프라인의 **[AI] Reviewer** 이며 **최종 코드 게이트**다.
시작 전에 반드시 읽는다: `CLAUDE.md`, `docs/pipeline/QUEUE-PROTOCOL.md`.
`git log`/`git diff` 로 이 이슈에서 변경된 내용을 검토한다.
## 검토 관점
- **정확성**: 로직 버그, 엣지케이스, 레이스, 잘못된 가정.
- **설계서 일치**: 구현이 `docs/design/` 설계서와 일치하는가. 설계서에 없는 임의 동작은
없는가. 설계가 바뀌었다면 설계서가 먼저 갱신됐는가. 큰 결정이 ADR 로 기록됐는가.
- **리스크/보안**: 비밀 노출, 주문/리스크 경로의 안전성, 입력 검증, 레이트리밋·재시도.
- **표준 준수**: CLAUDE.md 원칙(단일 책임, I/O 분리, 명시적 에러, 안전한 기본값).
- **테스트 충분성**: 핵심 로직이 테스트로 덮였는가.
## 게이트 결정 (둘 중 하나)
- **승인**: 문제 없음 → 카테고리 `07-Release`, 상태 신규. 승인 요지를 노트에 기록.
- **반려**: 결함 발견 → 카테고리 `03-Developer`, 상태 신규 로 반려,
노트에 **파일:라인 + 문제 + 권고 수정**을 구체적으로 남긴다.
- 사소한 스타일은 직접 고치고 승인해도 되나, 동작/보안 변경은 반드시 Developer 반려.
## 핸드오프
- 직접 수정 시 git 커밋·push (`[Reviewer] #<ID> ...`). 프로토콜 (a),(b),(c) 또는 §5.

View File

@@ -0,0 +1,5 @@
---
description: "Deprecated - use the superpowers:brainstorming skill instead"
---
Tell your human partner that this command is deprecated and will be removed in the next major release. They should ask you to use the "superpowers brainstorming" skill instead.

View File

@@ -0,0 +1,5 @@
---
description: "Deprecated - use the superpowers:executing-plans skill instead"
---
Tell your human partner that this command is deprecated and will be removed in the next major release. They should ask you to use the "superpowers executing-plans" skill instead.

View File

@@ -0,0 +1,5 @@
---
description: "Deprecated - use the superpowers:writing-plans skill instead"
---
Tell your human partner that this command is deprecated and will be removed in the next major release. They should ask you to use the "superpowers writing-plans" skill instead.

16
.claude/hooks.json Normal file
View File

@@ -0,0 +1,16 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear|compact",
"hooks": [
{
"type": "command",
"command": "'${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd' session-start",
"async": false
}
]
}
]
}
}

16
.claude/hooks/hooks.json Normal file
View File

@@ -0,0 +1,16 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear|compact",
"hooks": [
{
"type": "command",
"command": "'${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd' session-start",
"async": false
}
]
}
]
}
}

46
.claude/hooks/run-hook.cmd Executable file
View File

@@ -0,0 +1,46 @@
: << 'CMDBLOCK'
@echo off
REM Cross-platform polyglot wrapper for hook scripts.
REM On Windows: cmd.exe runs the batch portion, which finds and calls bash.
REM On Unix: the shell interprets this as a script (: is a no-op in bash).
REM
REM Hook scripts use extensionless filenames (e.g. "session-start" not
REM "session-start.sh") so Claude Code's Windows auto-detection -- which
REM prepends "bash" to any command containing .sh -- doesn't interfere.
REM
REM Usage: run-hook.cmd <script-name> [args...]
if "%~1"=="" (
echo run-hook.cmd: missing script name >&2
exit /b 1
)
set "HOOK_DIR=%~dp0"
REM Try Git for Windows bash in standard locations
if exist "C:\Program Files\Git\bin\bash.exe" (
"C:\Program Files\Git\bin\bash.exe" "%HOOK_DIR%%~1" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %ERRORLEVEL%
)
if exist "C:\Program Files (x86)\Git\bin\bash.exe" (
"C:\Program Files (x86)\Git\bin\bash.exe" "%HOOK_DIR%%~1" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %ERRORLEVEL%
)
REM Try bash on PATH (e.g. user-installed Git Bash, MSYS2, Cygwin)
where bash >nul 2>nul
if %ERRORLEVEL% equ 0 (
bash "%HOOK_DIR%%~1" %2 %3 %4 %5 %6 %7 %8 %9
exit /b %ERRORLEVEL%
)
REM No bash found - exit silently rather than error
REM (plugin still works, just without SessionStart context injection)
exit /b 0
CMDBLOCK
# Unix: run the named script directly
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT_NAME="$1"
shift
exec bash "${SCRIPT_DIR}/${SCRIPT_NAME}" "$@"

51
.claude/hooks/session-start Executable file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# SessionStart hook for superpowers plugin
set -euo pipefail
# Determine plugin root directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Check if legacy skills directory exists and build warning
warning_message=""
legacy_skills_dir="${HOME}/.config/superpowers/skills"
if [ -d "$legacy_skills_dir" ]; then
warning_message="\n\n<important-reminder>IN YOUR FIRST REPLY AFTER SEEING THIS MESSAGE YOU MUST TELL THE USER:⚠️ **WARNING:** Superpowers now uses Claude Code's skills system. Custom skills in ~/.config/superpowers/skills will not be read. Move custom skills to ~/.claude/skills instead. To make this message go away, remove ~/.config/superpowers/skills</important-reminder>"
fi
# Read using-superpowers content
using_superpowers_content=$(cat "${PLUGIN_ROOT}/skills/using-superpowers/SKILL.md" 2>&1 || echo "Error reading using-superpowers skill")
# Escape string for JSON embedding using bash parameter substitution.
# Each ${s//old/new} is a single C-level pass - orders of magnitude
# faster than the character-by-character loop this replaces.
escape_for_json() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\r'/\\r}"
s="${s//$'\t'/\\t}"
printf '%s' "$s"
}
using_superpowers_escaped=$(escape_for_json "$using_superpowers_content")
warning_escaped=$(escape_for_json "$warning_message")
session_context="<EXTREMELY_IMPORTANT>\nYou have superpowers.\n\n**Below is the full content of your 'superpowers:using-superpowers' skill - your introduction to using skills. For all other skills, use the 'Skill' tool:**\n\n${using_superpowers_escaped}\n\n${warning_escaped}\n</EXTREMELY_IMPORTANT>"
# Output context injection as JSON.
# Keep both shapes for compatibility:
# - Cursor hooks expect additional_context.
# - Claude hooks expect hookSpecificOutput.additionalContext.
cat <<EOF
{
"additional_context": "${session_context}",
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "${session_context}"
}
}
EOF
exit 0

View File

@@ -0,0 +1,213 @@
<!DOCTYPE html>
<html>
<head>
<title>Superpowers Brainstorming</title>
<style>
/*
* BRAINSTORM COMPANION FRAME TEMPLATE
*
* This template provides a consistent frame with:
* - OS-aware light/dark theming
* - Fixed header and selection indicator bar
* - Scrollable main content area
* - CSS helpers for common UI patterns
*
* Content is injected via placeholder comment in #claude-content.
*/
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
/* ===== THEME VARIABLES ===== */
:root {
--bg-primary: #f5f5f7;
--bg-secondary: #ffffff;
--bg-tertiary: #e5e5e7;
--border: #d1d1d6;
--text-primary: #1d1d1f;
--text-secondary: #86868b;
--text-tertiary: #aeaeb2;
--accent: #0071e3;
--accent-hover: #0077ed;
--success: #34c759;
--warning: #ff9f0a;
--error: #ff3b30;
--selected-bg: #e8f4fd;
--selected-border: #0071e3;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1d1d1f;
--bg-secondary: #2d2d2f;
--bg-tertiary: #3d3d3f;
--border: #424245;
--text-primary: #f5f5f7;
--text-secondary: #86868b;
--text-tertiary: #636366;
--accent: #0a84ff;
--accent-hover: #409cff;
--selected-bg: rgba(10, 132, 255, 0.15);
--selected-border: #0a84ff;
}
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
flex-direction: column;
line-height: 1.5;
}
/* ===== FRAME STRUCTURE ===== */
.header {
background: var(--bg-secondary);
padding: 0.5rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.header h1 { font-size: 0.85rem; font-weight: 500; color: var(--text-secondary); }
.header .status { font-size: 0.7rem; color: var(--success); display: flex; align-items: center; gap: 0.4rem; }
.header .status::before { content: ''; width: 6px; height: 6px; background: var(--success); border-radius: 50%; }
.main { flex: 1; overflow-y: auto; }
#claude-content { padding: 2rem; min-height: 100%; }
.indicator-bar {
background: var(--bg-secondary);
border-top: 1px solid var(--border);
padding: 0.5rem 1.5rem;
flex-shrink: 0;
text-align: center;
}
.indicator-bar span {
font-size: 0.75rem;
color: var(--text-secondary);
}
.indicator-bar .selected-text {
color: var(--accent);
font-weight: 500;
}
/* ===== TYPOGRAPHY ===== */
h2 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.5rem; }
h3 { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.25rem; }
.subtitle { color: var(--text-secondary); margin-bottom: 1.5rem; }
.section { margin-bottom: 2rem; }
.label { font-size: 0.7rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
/* ===== OPTIONS (for A/B/C choices) ===== */
.options { display: flex; flex-direction: column; gap: 0.75rem; }
.option {
background: var(--bg-secondary);
border: 2px solid var(--border);
border-radius: 12px;
padding: 1rem 1.25rem;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: flex-start;
gap: 1rem;
}
.option:hover { border-color: var(--accent); }
.option.selected { background: var(--selected-bg); border-color: var(--selected-border); }
.option .letter {
background: var(--bg-tertiary);
color: var(--text-secondary);
width: 1.75rem; height: 1.75rem;
border-radius: 6px;
display: flex; align-items: center; justify-content: center;
font-weight: 600; font-size: 0.85rem; flex-shrink: 0;
}
.option.selected .letter { background: var(--accent); color: white; }
.option .content { flex: 1; }
.option .content h3 { font-size: 0.95rem; margin-bottom: 0.15rem; }
.option .content p { color: var(--text-secondary); font-size: 0.85rem; margin: 0; }
/* ===== CARDS (for showing designs/mockups) ===== */
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; }
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.15s ease;
}
.card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.card.selected { border-color: var(--selected-border); border-width: 2px; }
.card-image { background: var(--bg-tertiary); aspect-ratio: 16/10; display: flex; align-items: center; justify-content: center; }
.card-body { padding: 1rem; }
.card-body h3 { margin-bottom: 0.25rem; }
.card-body p { color: var(--text-secondary); font-size: 0.85rem; }
/* ===== MOCKUP CONTAINER ===== */
.mockup {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
margin-bottom: 1.5rem;
}
.mockup-header {
background: var(--bg-tertiary);
padding: 0.5rem 1rem;
font-size: 0.75rem;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
}
.mockup-body { padding: 1.5rem; }
/* ===== SPLIT VIEW (side-by-side comparison) ===== */
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
@media (max-width: 700px) { .split { grid-template-columns: 1fr; } }
/* ===== PROS/CONS ===== */
.pros-cons { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1rem 0; }
.pros, .cons { background: var(--bg-secondary); border-radius: 8px; padding: 1rem; }
.pros h4 { color: var(--success); font-size: 0.85rem; margin-bottom: 0.5rem; }
.cons h4 { color: var(--error); font-size: 0.85rem; margin-bottom: 0.5rem; }
.pros ul, .cons ul { margin-left: 1.25rem; font-size: 0.85rem; color: var(--text-secondary); }
.pros li, .cons li { margin-bottom: 0.25rem; }
/* ===== PLACEHOLDER (for mockup areas) ===== */
.placeholder {
background: var(--bg-tertiary);
border: 2px dashed var(--border);
border-radius: 8px;
padding: 2rem;
text-align: center;
color: var(--text-tertiary);
}
/* ===== INLINE MOCKUP ELEMENTS ===== */
.mock-nav { background: var(--accent); color: white; padding: 0.75rem 1rem; display: flex; gap: 1.5rem; font-size: 0.9rem; }
.mock-sidebar { background: var(--bg-tertiary); padding: 1rem; min-width: 180px; }
.mock-content { padding: 1.5rem; flex: 1; }
.mock-button { background: var(--accent); color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.85rem; }
.mock-input { background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem; width: 100%; }
</style>
</head>
<body>
<div class="header">
<h1><a href="https://github.com/obra/superpowers" style="color: inherit; text-decoration: none;">Superpowers Brainstorming</a></h1>
<div class="status">Connected</div>
</div>
<div class="main">
<div id="claude-content">
<!-- CONTENT -->
</div>
</div>
<div class="indicator-bar">
<span id="indicator-text">Click an option above, then return to the terminal</span>
</div>
</body>
</html>

View File

@@ -0,0 +1,88 @@
(function() {
const WS_URL = 'ws://' + window.location.host;
let ws = null;
let eventQueue = [];
function connect() {
ws = new WebSocket(WS_URL);
ws.onopen = () => {
eventQueue.forEach(e => ws.send(JSON.stringify(e)));
eventQueue = [];
};
ws.onmessage = (msg) => {
const data = JSON.parse(msg.data);
if (data.type === 'reload') {
window.location.reload();
}
};
ws.onclose = () => {
setTimeout(connect, 1000);
};
}
function sendEvent(event) {
event.timestamp = Date.now();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(event));
} else {
eventQueue.push(event);
}
}
// Capture clicks on choice elements
document.addEventListener('click', (e) => {
const target = e.target.closest('[data-choice]');
if (!target) return;
sendEvent({
type: 'click',
text: target.textContent.trim(),
choice: target.dataset.choice,
id: target.id || null
});
// Update indicator bar (defer so toggleSelect runs first)
setTimeout(() => {
const indicator = document.getElementById('indicator-text');
if (!indicator) return;
const container = target.closest('.options') || target.closest('.cards');
const selected = container ? container.querySelectorAll('.selected') : [];
if (selected.length === 0) {
indicator.textContent = 'Click an option above, then return to the terminal';
} else if (selected.length === 1) {
const label = selected[0].querySelector('h3, .content h3, .card-body h3')?.textContent?.trim() || selected[0].dataset.choice;
indicator.innerHTML = '<span class="selected-text">' + label + ' selected</span> — return to terminal to continue';
} else {
indicator.innerHTML = '<span class="selected-text">' + selected.length + ' selected</span> — return to terminal to continue';
}
}, 0);
});
// Frame UI: selection tracking
window.selectedChoice = null;
window.toggleSelect = function(el) {
const container = el.closest('.options') || el.closest('.cards');
const multi = container && container.dataset.multiselect !== undefined;
if (container && !multi) {
container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected'));
}
if (multi) {
el.classList.toggle('selected');
} else {
el.classList.add('selected');
}
window.selectedChoice = el.dataset.choice;
};
// Expose API for explicit use
window.brainstorm = {
send: sendEvent,
choice: (value, metadata = {}) => sendEvent({ type: 'choice', value, ...metadata })
};
connect();
})();

View File

@@ -0,0 +1,141 @@
const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const chokidar = require('chokidar');
const fs = require('fs');
const path = require('path');
const PORT = process.env.BRAINSTORM_PORT || (49152 + Math.floor(Math.random() * 16383));
const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1';
const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
const SCREEN_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
if (!fs.existsSync(SCREEN_DIR)) {
fs.mkdirSync(SCREEN_DIR, { recursive: true });
}
// Load frame template and helper script once at startup
const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
const helperInjection = `<script>\n${helperScript}\n</script>`;
// Detect whether content is a full HTML document or a bare fragment
function isFullDocument(html) {
const trimmed = html.trimStart().toLowerCase();
return trimmed.startsWith('<!doctype') || trimmed.startsWith('<html');
}
// Wrap a content fragment in the frame template
function wrapInFrame(content) {
return frameTemplate.replace('<!-- CONTENT -->', content);
}
// Find the newest .html file in the directory by mtime
function getNewestScreen() {
const files = fs.readdirSync(SCREEN_DIR)
.filter(f => f.endsWith('.html'))
.map(f => ({
name: f,
path: path.join(SCREEN_DIR, f),
mtime: fs.statSync(path.join(SCREEN_DIR, f)).mtime.getTime()
}))
.sort((a, b) => b.mtime - a.mtime);
return files.length > 0 ? files[0].path : null;
}
const WAITING_PAGE = `<!DOCTYPE html>
<html>
<head>
<title>Brainstorm Companion</title>
<style>
body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
h1 { color: #333; }
p { color: #666; }
</style>
</head>
<body>
<h1>Brainstorm Companion</h1>
<p>Waiting for Claude to push a screen...</p>
</body>
</html>`;
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
const clients = new Set();
wss.on('connection', (ws) => {
clients.add(ws);
ws.on('close', () => clients.delete(ws));
ws.on('message', (data) => {
const event = JSON.parse(data.toString());
console.log(JSON.stringify({ source: 'user-event', ...event }));
// Write user events to .events file for Claude to read
if (event.choice) {
const eventsFile = path.join(SCREEN_DIR, '.events');
fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
}
});
});
// Serve newest screen with helper.js injected
app.get('/', (req, res) => {
const screenFile = getNewestScreen();
let html;
if (!screenFile) {
html = WAITING_PAGE;
} else {
const raw = fs.readFileSync(screenFile, 'utf-8');
html = isFullDocument(raw) ? raw : wrapInFrame(raw);
}
// Inject helper script
if (html.includes('</body>')) {
html = html.replace('</body>', `${helperInjection}\n</body>`);
} else {
html += helperInjection;
}
res.type('html').send(html);
});
// Watch for new or changed .html files
chokidar.watch(SCREEN_DIR, { ignoreInitial: true })
.on('add', (filePath) => {
if (filePath.endsWith('.html')) {
// Clear events from previous screen
const eventsFile = path.join(SCREEN_DIR, '.events');
if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
clients.forEach(ws => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'reload' }));
}
});
}
})
.on('change', (filePath) => {
if (filePath.endsWith('.html')) {
console.log(JSON.stringify({ type: 'screen-updated', file: filePath }));
clients.forEach(ws => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'reload' }));
}
});
}
});
server.listen(PORT, HOST, () => {
console.log(JSON.stringify({
type: 'server-started',
port: PORT,
host: HOST,
url_host: URL_HOST,
url: `http://${URL_HOST}:${PORT}`,
screen_dir: SCREEN_DIR
}));
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
{
"name": "brainstorm-server",
"version": "1.0.0",
"description": "Visual brainstorming companion server for Claude Code",
"main": "index.js",
"dependencies": {
"chokidar": "^3.5.3",
"express": "^4.18.2",
"ws": "^8.14.2"
}
}

View File

@@ -0,0 +1,129 @@
#!/bin/bash
# Start the brainstorm server and output connection info
# Usage: start-server.sh [--project-dir <path>] [--host <bind-host>] [--url-host <display-host>] [--foreground] [--background]
#
# Starts server on a random high port, outputs JSON with URL.
# Each session gets its own directory to avoid conflicts.
#
# Options:
# --project-dir <path> Store session files under <path>/.superpowers/brainstorm/
# instead of /tmp. Files persist after server stops.
# --host <bind-host> Host/interface to bind (default: 127.0.0.1).
# Use 0.0.0.0 in remote/containerized environments.
# --url-host <host> Hostname shown in returned URL JSON.
# --foreground Run server in the current terminal (no backgrounding).
# --background Force background mode (overrides Codex auto-foreground).
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Parse arguments
PROJECT_DIR=""
FOREGROUND="false"
FORCE_BACKGROUND="false"
BIND_HOST="127.0.0.1"
URL_HOST=""
while [[ $# -gt 0 ]]; do
case "$1" in
--project-dir)
PROJECT_DIR="$2"
shift 2
;;
--host)
BIND_HOST="$2"
shift 2
;;
--url-host)
URL_HOST="$2"
shift 2
;;
--foreground|--no-daemon)
FOREGROUND="true"
shift
;;
--background|--daemon)
FORCE_BACKGROUND="true"
shift
;;
*)
echo "{\"error\": \"Unknown argument: $1\"}"
exit 1
;;
esac
done
if [[ -z "$URL_HOST" ]]; then
if [[ "$BIND_HOST" == "127.0.0.1" || "$BIND_HOST" == "localhost" ]]; then
URL_HOST="localhost"
else
URL_HOST="$BIND_HOST"
fi
fi
# Codex environments may reap detached/background processes. Prefer foreground by default.
if [[ -n "${CODEX_CI:-}" && "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then
FOREGROUND="true"
fi
# Generate unique session directory
SESSION_ID="$$-$(date +%s)"
if [[ -n "$PROJECT_DIR" ]]; then
SCREEN_DIR="${PROJECT_DIR}/.superpowers/brainstorm/${SESSION_ID}"
else
SCREEN_DIR="/tmp/brainstorm-${SESSION_ID}"
fi
PID_FILE="${SCREEN_DIR}/.server.pid"
LOG_FILE="${SCREEN_DIR}/.server.log"
# Create fresh session directory
mkdir -p "$SCREEN_DIR"
# Kill any existing server
if [[ -f "$PID_FILE" ]]; then
old_pid=$(cat "$PID_FILE")
kill "$old_pid" 2>/dev/null
rm -f "$PID_FILE"
fi
cd "$SCRIPT_DIR"
# Foreground mode for environments that reap detached/background processes.
if [[ "$FOREGROUND" == "true" ]]; then
echo "$$" > "$PID_FILE"
env BRAINSTORM_DIR="$SCREEN_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" node index.js
exit $?
fi
# Start server, capturing output to log file
# Use nohup to survive shell exit; disown to remove from job table
nohup env BRAINSTORM_DIR="$SCREEN_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" node index.js > "$LOG_FILE" 2>&1 &
SERVER_PID=$!
disown "$SERVER_PID" 2>/dev/null
echo "$SERVER_PID" > "$PID_FILE"
# Wait for server-started message (check log file)
for i in {1..50}; do
if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then
# Verify server is still alive after a short window (catches process reapers)
alive="true"
for _ in {1..20}; do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
alive="false"
break
fi
sleep 0.1
done
if [[ "$alive" != "true" ]]; then
echo "{\"error\": \"Server started but was killed. Retry in a persistent terminal with: $SCRIPT_DIR/start-server.sh${PROJECT_DIR:+ --project-dir $PROJECT_DIR} --host $BIND_HOST --url-host $URL_HOST --foreground\"}"
exit 1
fi
grep "server-started" "$LOG_FILE" | head -1
exit 0
fi
sleep 0.1
done
# Timeout - server didn't start
echo '{"error": "Server failed to start within 5 seconds"}'
exit 1

View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Stop the brainstorm server and clean up
# Usage: stop-server.sh <screen_dir>
#
# Kills the server process. Only deletes session directory if it's
# under /tmp (ephemeral). Persistent directories (.superpowers/) are
# kept so mockups can be reviewed later.
SCREEN_DIR="$1"
if [[ -z "$SCREEN_DIR" ]]; then
echo '{"error": "Usage: stop-server.sh <screen_dir>"}'
exit 1
fi
PID_FILE="${SCREEN_DIR}/.server.pid"
if [[ -f "$PID_FILE" ]]; then
pid=$(cat "$PID_FILE")
kill "$pid" 2>/dev/null
rm -f "$PID_FILE" "${SCREEN_DIR}/.server.log"
# Only delete ephemeral /tmp directories
if [[ "$SCREEN_DIR" == /tmp/* ]]; then
rm -rf "$SCREEN_DIR"
fi
echo '{"status": "stopped"}'
else
echo '{"status": "not_running"}'
fi

208
.claude/lib/skills-core.js Normal file
View File

@@ -0,0 +1,208 @@
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
/**
* Extract YAML frontmatter from a skill file.
* Current format:
* ---
* name: skill-name
* description: Use when [condition] - [what it does]
* ---
*
* @param {string} filePath - Path to SKILL.md file
* @returns {{name: string, description: string}}
*/
function extractFrontmatter(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n');
let inFrontmatter = false;
let name = '';
let description = '';
for (const line of lines) {
if (line.trim() === '---') {
if (inFrontmatter) break;
inFrontmatter = true;
continue;
}
if (inFrontmatter) {
const match = line.match(/^(\w+):\s*(.*)$/);
if (match) {
const [, key, value] = match;
switch (key) {
case 'name':
name = value.trim();
break;
case 'description':
description = value.trim();
break;
}
}
}
}
return { name, description };
} catch (error) {
return { name: '', description: '' };
}
}
/**
* Find all SKILL.md files in a directory recursively.
*
* @param {string} dir - Directory to search
* @param {string} sourceType - 'personal' or 'superpowers' for namespacing
* @param {number} maxDepth - Maximum recursion depth (default: 3)
* @returns {Array<{path: string, name: string, description: string, sourceType: string}>}
*/
function findSkillsInDir(dir, sourceType, maxDepth = 3) {
const skills = [];
if (!fs.existsSync(dir)) return skills;
function recurse(currentDir, depth) {
if (depth > maxDepth) return;
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
// Check for SKILL.md in this directory
const skillFile = path.join(fullPath, 'SKILL.md');
if (fs.existsSync(skillFile)) {
const { name, description } = extractFrontmatter(skillFile);
skills.push({
path: fullPath,
skillFile: skillFile,
name: name || entry.name,
description: description || '',
sourceType: sourceType
});
}
// Recurse into subdirectories
recurse(fullPath, depth + 1);
}
}
}
recurse(dir, 0);
return skills;
}
/**
* Resolve a skill name to its file path, handling shadowing
* (personal skills override superpowers skills).
*
* @param {string} skillName - Name like "superpowers:brainstorming" or "my-skill"
* @param {string} superpowersDir - Path to superpowers skills directory
* @param {string} personalDir - Path to personal skills directory
* @returns {{skillFile: string, sourceType: string, skillPath: string} | null}
*/
function resolveSkillPath(skillName, superpowersDir, personalDir) {
// Strip superpowers: prefix if present
const forceSuperpowers = skillName.startsWith('superpowers:');
const actualSkillName = forceSuperpowers ? skillName.replace(/^superpowers:/, '') : skillName;
// Try personal skills first (unless explicitly superpowers:)
if (!forceSuperpowers && personalDir) {
const personalPath = path.join(personalDir, actualSkillName);
const personalSkillFile = path.join(personalPath, 'SKILL.md');
if (fs.existsSync(personalSkillFile)) {
return {
skillFile: personalSkillFile,
sourceType: 'personal',
skillPath: actualSkillName
};
}
}
// Try superpowers skills
if (superpowersDir) {
const superpowersPath = path.join(superpowersDir, actualSkillName);
const superpowersSkillFile = path.join(superpowersPath, 'SKILL.md');
if (fs.existsSync(superpowersSkillFile)) {
return {
skillFile: superpowersSkillFile,
sourceType: 'superpowers',
skillPath: actualSkillName
};
}
}
return null;
}
/**
* Check if a git repository has updates available.
*
* @param {string} repoDir - Path to git repository
* @returns {boolean} - True if updates are available
*/
function checkForUpdates(repoDir) {
try {
// Quick check with 3 second timeout to avoid delays if network is down
const output = execSync('git fetch origin && git status --porcelain=v1 --branch', {
cwd: repoDir,
timeout: 3000,
encoding: 'utf8',
stdio: 'pipe'
});
// Parse git status output to see if we're behind
const statusLines = output.split('\n');
for (const line of statusLines) {
if (line.startsWith('## ') && line.includes('[behind ')) {
return true; // We're behind remote
}
}
return false; // Up to date
} catch (error) {
// Network down, git error, timeout, etc. - don't block bootstrap
return false;
}
}
/**
* Strip YAML frontmatter from skill content, returning just the content.
*
* @param {string} content - Full content including frontmatter
* @returns {string} - Content without frontmatter
*/
function stripFrontmatter(content) {
const lines = content.split('\n');
let inFrontmatter = false;
let frontmatterEnded = false;
const contentLines = [];
for (const line of lines) {
if (line.trim() === '---') {
if (inFrontmatter) {
frontmatterEnded = true;
continue;
}
inFrontmatter = true;
continue;
}
if (frontmatterEnded || !inFrontmatter) {
contentLines.push(line);
}
}
return contentLines.join('\n').trim();
}
export {
extractFrontmatter,
findSkillsInDir,
resolveSkillPath,
checkForUpdates,
stripFrontmatter
};

35
.claude/settings.json Normal file
View File

@@ -0,0 +1,35 @@
{
"permissions": {
"allow": [
"Bash",
"Read",
"Edit",
"Write",
"WebFetch",
"WebSearch",
"mcp__searxng__web_search",
"mcp__oracle-26ai-vector__search_similar"
],
"deny": [
"Bash(rm -rf /)",
"Bash(rm -rf /*)",
"Bash(rm -rf ~)",
"Bash(rm -rf ~/*)",
"Bash(sudo rm -rf *)",
"Edit(//Users/joungmin/.claude/settings.json)",
"Write(//Users/joungmin/.claude/settings.json)",
"Edit(//Users/joungmin/.claude/settings.local.json)",
"Write(//Users/joungmin/.claude/settings.local.json)",
"Edit(//Users/joungmin/.claude/agents/**)",
"Write(//Users/joungmin/.claude/agents/**)",
"Edit(//Users/joungmin/.claude/commands/**)",
"Write(//Users/joungmin/.claude/commands/**)",
"Edit(//Users/joungmin/.claude/CLAUDE.md)",
"Write(//Users/joungmin/.claude/CLAUDE.md)",
"Edit(//Users/joungmin/.gitconfig)",
"Write(//Users/joungmin/.gitconfig)",
"Edit(.git/**)",
"Write(.git/**)"
]
}
}

View File

@@ -0,0 +1,89 @@
export const meta = {
name: 'persona-pipeline',
description: 'tasteby: Redmine 큐의 열린 이슈를 8개 AI 페르소나 단계로 자동 통과시킨다 (Planner→...→Documenter, QA/Reviewer 반려 루프 포함)',
phases: [
{ title: 'Scan', detail: 'Redmine tasteby 의 열린 이슈와 현재 단계 수집' },
{ title: 'Pipeline', detail: '각 이슈를 현재 단계 페르소나부터 Done 까지 구동' },
],
}
// ── 단계 정의 ──────────────────────────────────────────────
const ORDER = ['01-Planner','02-Architect','03-Developer','04-QA','05-Designer','06-Reviewer','07-Release','08-Documenter']
const PERSONA = {
'01-Planner':'planner', '02-Architect':'architect', '03-Developer':'developer', '04-QA':'qa',
'05-Designer':'designer', '06-Reviewer':'reviewer', '07-Release':'release', '08-Documenter':'documenter',
}
const DONE = '09-Done'
const nextOf = (s) => { const i = ORDER.indexOf(s); return i < 0 ? '01-Planner' : (i+1 < ORDER.length ? ORDER[i+1] : DONE) }
const SCAN_SCHEMA = {
type: 'object', additionalProperties: false,
required: ['issues'],
properties: { issues: { type: 'array', items: {
type: 'object', additionalProperties: false, required: ['id','subject','currentStage'],
properties: { id: {type:'integer'}, subject: {type:'string'}, currentStage: {type:'string'} },
} } },
}
const STAGE_SCHEMA = {
type: 'object', additionalProperties: false,
required: ['issueId','persona','outcome','nextStage','summary'],
properties: {
issueId: { type: 'integer' },
persona: { type: 'string' },
outcome: { type: 'string', enum: ['advanced','rejected','done','blocked'] },
nextStage: { type: 'string', description: '다음 Redmine 카테고리명 (예: 04-QA, 03-Developer, 09-Done)' },
summary: { type: 'string' },
commitSha: { type: 'string' },
},
}
// ── 1. 큐 스캔 ─────────────────────────────────────────────
phase('Scan')
const scan = await agent(
`너는 tasteby 파이프라인 디스패처다. \`.env\` 를 로드(set -a; . ./.env; set +a)해서 ` +
`REDMINE_URL/REDMINE_API_KEY 를 얻은 뒤, 프로젝트 tasteby 에서 **열린(open)** 이슈를 모두 조회한다:\n` +
` curl -s -H "X-Redmine-API-Key: $REDMINE_API_KEY" "$REDMINE_URL/issues.json?project_id=tasteby&status_id=open&limit=100"\n` +
`각 이슈의 현재 단계 = 카테고리 이름(category.name). 카테고리가 없으면 "01-Planner" 로 본다.\n` +
`09-Done 이거나 닫힌 이슈는 제외한다. id·subject·currentStage 목록을 반환하라.`,
{ schema: SCAN_SCHEMA, phase: 'Scan', label: 'scan-queue' }
)
const queue = (scan && scan.issues) ? scan.issues : []
if (!queue.length) {
log('큐가 비어 있다. 처리할 열린 이슈가 없음. Redmine 에 작업 이슈를 추가한 뒤 다시 실행하라.')
return { processed: 0, message: 'empty queue' }
}
log(`큐에 ${queue.length}개 이슈: ` + queue.map(i => `#${i.id}(${i.currentStage})`).join(', '))
// ── 2. 각 이슈를 단계별로 구동 (이슈끼리는 병렬) ─────────────
phase('Pipeline')
const MAX_STEPS = 24 // 반려 핑퐁 등 무한루프 방지
const results = await parallel(queue.map((issue) => async () => {
let stage = issue.currentStage || '01-Planner'
if (!ORDER.includes(stage)) stage = '01-Planner'
const trail = []
for (let step = 0; step < MAX_STEPS && stage !== DONE; step++) {
const persona = PERSONA[stage]
if (!persona) { log(`#${issue.id}: 알 수 없는 단계 ${stage} — 중단`); break }
const res = await agent(
`Redmine 이슈 #${issue.id} ("${issue.subject}") 를 처리하라. 현재 단계: ${stage}.\n` +
`너의 역할 정의와 docs/pipeline/QUEUE-PROTOCOL.md 를 따라 작업하고, ` +
`결과(파일 변경 git 커밋/push, Redmine 저널 노트, 다음 단계로 카테고리·상태 전진/반려)를 모두 수행하라.\n` +
`완료 후 구조화 결과를 반환하라. nextStage 는 네가 실제로 Redmine 에 설정한 카테고리명이어야 한다.`,
{ agentType: persona, schema: STAGE_SCHEMA, phase: 'Pipeline', label: `${persona}#${issue.id}` }
)
if (!res) { log(`#${issue.id}: ${persona} 단계 결과 없음 — 중단`); break }
trail.push(`${persona}:${res.outcome}`)
log(`#${issue.id} ${persona}${res.outcome} (${res.summary || ''})`.slice(0, 200))
if (res.outcome === 'blocked') { log(`#${issue.id}: ${stage} 에서 블록됨 — ${res.summary}`); break }
const reported = (res.nextStage || '').trim()
stage = (ORDER.includes(reported) || reported === DONE) ? reported : nextOf(stage)
}
return { id: issue.id, finalStage: stage, done: stage === DONE, trail }
}))
const summary = results.filter(Boolean)
log('완료: ' + summary.map(r => `#${r.id}=${r.done ? 'DONE' : r.finalStage}`).join(', '))
return { processed: summary.length, results: summary }

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
REDMINE_URL=
REDMINE_API_KEY=
REDMINE_PROJECT=tasteby
GITEA_URL=
GITEA_USER=
GITEA_EMAIL=
GITEA_PASSWORD=
GITEA_REPO=tasteby

3
.gitignore vendored
View File

@@ -2,6 +2,7 @@ __pycache__/
*.pyc
.venv/
.env
.ch-backup/
node_modules/
.next/
.env.local
@@ -17,3 +18,5 @@ k8s/secrets.yaml
# OS / misc
.DS_Store
backend/cookies.txt
backend-java/cookies.txt
**/cookies.txt

448
CHANGELOG.md Normal file
View File

@@ -0,0 +1,448 @@
# Tasteby 작업 기록
> 작업 내용, 이슈, 해결 방법을 기록하는 문서. 커밋/배포 시 참고용.
---
## 2026-06-16
### 🎨 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
### 🐛 캐치테이블 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)
- 공통 훅 `frontend/src/lib/hooks/useModalA11y.ts` 신규 (useEscapeKey, useFocusTrap, useBodyScrollLock)
- BottomSheet/FilterSheet: role='dialog', aria-modal, aria-label/labelledby, ESC 닫기, focus trap
- RestaurantDetail: useEffect cancelled 플래그로 restaurant.id 변경 시 race condition 차단
- page.tsx: `exitSearchMode` 헬퍼 → 검색결과 모드에서 필터 변경 시 자동 검색 모드 해제 + 원본 재로드
- 후속 분리: #319 (BottomSheet 매직넘버/UX), #320 (필터 정밀도/접근성/테스트)
- Refs: #301 #302 (close)
### 🔧 #316 — backend resource request 재산정 + RollingUpdate 정책 복귀
- **변경 전**: cpu 500m/1, mem 768Mi/1536Mi, strategy maxSurge=0/maxUnavailable=1 (임시 패치)
- **변경 후**: cpu 300m/800m, mem 512Mi/1024Mi, strategy 25%/25% (기본 복귀)
- **근거**: 실측 idle 0.7% CPU, RSS ~305 MB. peak 30-40% 추정 안에서 안전.
- **검증**: rollout 후 노드 잔여 330m → 다음 배포 시 두 Pod 공존 가능, 무중단 RollingUpdate 회복.
- **다운타임**: 이번 1회 ~25초 (구 Pod 500m 점유 해제 위해 강제 종료). 다음 배포부터 0초.
- **설계서**: `docs/design/316-backend-resource-rightsize/README.md` (Approved).
- Refs: #316 (close)
### 🏗 OKE 인프라 — 노드 다운사이징 + LB 정리
- **Orphan Classic LB 삭제**: 132.226.175.247 (100Mbps shape, OKEclusterName 태그만 남고 DNS/Service 참조 없음) → 비용 절감
- **노드풀 교체 (블루-그린)**: `pool1` (2 노드 × 2 OCPU / 8 GB) → `pool2` (2 노드 × 1 OCPU / 6 GB)
- 사유: ARM64 Always Free 쿼터 변경 (4 OCPU/24 GB → 2 OCPU/12 GB)
- 절차: 새 노드풀 생성 → 기존 노드 cordon + drain → 기존 노드풀 삭제 → 무중단 확인
- **backend Deployment strategy 임시 패치**: `maxSurge: 25% → 0`, `maxUnavailable: 25% → 1`
- 노드당 1 OCPU 환경에서 backend(500m 요청) 두 Pod 공존 불가 → rollingUpdate 데드락 회피
- **⚠️ 다음 배포 시 ~30초 다운타임** 발생. 후속 이슈에서 resource request 재산정 권고.
### 🚀 운영 배포 v0.1.13
- 보안 핫픽스 #267 배포 (백엔드만)
- OCIR push + kubectl rolling update + git tag v0.1.13 완료
- 검증: `Anonymous /api/admin/users → 403`, `Bad-token → 403`, `정상 동작 영향 없음`
### 🔴 보안 핫픽스 #267 — AdminUserController GET 4종 권한 우회
- `listUsers`, `userFavorites`, `userReviews`, `userMemos`가 인증만 요구하고 admin 검사를 하지 않아 일반 사용자 토큰으로 전체 사용자 목록 및 타인 활동 조회 가능했음
- 4개 메서드 첫 줄에 `AuthUtil.requireAdmin()` 추가 → non-admin 호출 시 403
- 설계서 §3 인수조건에 `/api/admin/users/**` 권한 강제 항목 추가
- Refs: #267 (현행화 Reviewer 반려 → Developer 수정 → 다시 통과)
### ch-bootstrap 적용 (페르소나 파이프라인 + Design-First)
- Redmine 8단계 페르소나 큐(`01-Planner` ~ `09-Done`) + 9개 카테고리 자동 생성
- Design-First 게이트(설계서 없으면 코드 작성 금지) 도입
- `.claude/agents/` 8개 페르소나 + `.claude/workflows/persona-pipeline.js`
- 안전-최대 권한 정책(`.claude/settings.json`)
- `docs/{design,adr,pipeline}/` 골격 + `scripts/enqueue.sh`
- 기존 Tasteby 고유 규칙(존댓말, CHANGELOG, 디자인 패턴, CORS, PM2)은 `CLAUDE.md` 0/7/8장으로 보존
- Redmine 프로젝트 description + Wiki 4페이지(Overview/Dev-Env/Prod-Env/Deploy) 작성
### tasteby 기존 18개 기능 Design-First 현행화
- 백엔드 12개(auth/user/restaurant/video/extract-pipeline/search/review-memo/channel/stats/daemon/cache/health) + 프론트 6개(map/restaurant-detail/filter/review-memo/admin/login)
- 각 기능별 `docs/design/<issue>-<slug>/README.md` 12개 섹션 채움 (총 3,830줄)
- 추적성: 각 설계서가 구현 파일/Redmine 이슈/커밋 SHA와 연결됨
- **Reviewer 결과**: 17 PASS w/notes, 1 REJECT (#267 admin 권한 critical)
- 후속 17개 개선 이슈(#289~#305) 자동 등록 — 결함 총 124건(critical 3 / major 46 / minor 75) 백로그 반영
- 코드 변경 없음 — 문서화 + 백로그화 전용
---
## 2026-04-04
### 코드 리뷰 스크립트 추가 + 리뷰 지적사항 반영
- `scripts/code_review.py`: 페르소나 기반 코드 리뷰 스크립트 (OpenRouter API, 프론트/백엔드/보안/아키텍처 4관점)
- `UserService.updateAdmin()`: 존재하지 않는 userId에 대해 404 응답 추가
- `AdminUserController.updateAdmin()`: 자기 자신 admin 권한 변경 차단 + 감사 로그 추가 + 응답에 변경 결과 포함
- `JsonUtil.normalizeEvaluation()`: evaluation 정규화 로직을 공통 유틸로 통합 (RestaurantService, VideoService 중복 제거)
- `RestaurantService.linkVideoRestaurant()`: evaluation 저장 시 평문→JSON 정규화 + 300자 제한
### 가격대 필터 5단계 세분화
- 기존 3단계(저렴/보통/고가) → 5단계(저렴/가성비/보통/프리미엄/럭셔리)
- `PRICE_GROUPS` 상수 수정, 정규식 패턴 세분화
### 모바일 터치 영역 개선 (44×44px 통일)
- **별점 선택기**: 0.5단위 10개 숫자 버튼(24px) → 별 아이콘 5개(44px), 탭으로 정수/반점수 전환
- **FilterSheet 닫기 버튼**: `p-1``p-2` (터치 영역 확대)
- **RestaurantDetail 찜 버튼**: 패딩 추가 + `touch-manipulation` 적용
- **필터 초기화 X 버튼**: 아이콘 12px → 14px + 패딩 추가
### 채널 필터 시 식당이 3개만 나오는 버그 수정
- **원인**: 전체 식당 500개만 가져와서 클라이언트 필터링 → 특정 채널 식당이 상위 500개에 일부만 포함
- **수정**: `page.tsx`에서 채널 필터 변경 시 서버에 `channel` 파라미터를 보내 서버 사이드 필터링 적용
---
## 2026-03-29
### 식당 평가(evaluation) 표시 안 되는 버그 수정
- **원인**: LLM이 추출한 evaluation이 대부분 평문 문자열로 DB에 저장되어 있었으나, 프론트에서 `evaluation.text`로 접근하여 표시되지 않음
- **수정**:
- `JsonUtil.parseMap()`: JSON 파싱 실패 시 `{"text":"원본문자열"}`로 감싸서 반환
- `VideoService.findDetail()`: `VideoRestaurantLink`의 evaluation 평문을 JSON 객체로 정규화
---
## 2026-03-16
### Admin 유저 관리 — 관리자 권한 토글 기능 추가
- **Backend**
- `UserMapper.xml`: `findAllWithCounts``is_admin` 컬럼 추가, `updateAdmin` 쿼리 추가
- `UserMapper.java`: `updateAdmin()` 메서드 추가
- `UserService.java`: `updateAdmin()` 메서드 추가
- `AdminUserController.java`: `PATCH /api/admin/users/{userId}/admin` 엔드포인트 추가
- **Frontend**
- `api.ts`: `updateAdminUserAdmin()` API 함수 추가, 유저 타입에 `is_admin` 필드 추가
- `admin/page.tsx`: 유저 테이블에 "관리자" 컬럼 + ON/OFF 토글 버튼 추가
### CORS PATCH 메서드 허용
- **문제**: PATCH 요청 시 CORS preflight(OPTIONS)에서 403 차단
- **원인**: `WebConfig.java``allowedMethods``PATCH`가 빠져 있었음
- **해결**: `List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")``"PATCH"` 추가
### Icon 시스템 개선
- Material Symbols `sake` 아이콘 종횡비 문제 수정 — `width`/`height``fontSize`와 동일하게 고정 + `overflow: hidden`
- 이자카야 아이콘: `sake``local_bar` (술잔 모양으로 변경)
- 삼겹살/돼지구이, 족발/보쌈, 돈카츠: `PiggyBank``food:pig` (커스텀 돼지 SVG)
### LLM 추출 프롬프트 수정
- `ExtractorService.java`: `evaluation` 필드 → "평가 내용을 100자 이내로 요약"으로 변경
### 브랜드 가이드 문서 생성
- `frontend/docs/brand-guide.md`: 브랜드 아이덴티티, 컬러, 타이포, 아이콘 정책 등 정리
### PM2 프론트엔드 포트 고정
- **문제**: `pm2 restart` 후 Next.js가 3000(Gitea 포트)으로 fallback → nginx 502
- **해결**: PM2에 `PORT=3001` 환경변수 고정하여 재등록 + `pm2 save`
---
## 2026-03-14
### 홈 탭 장르 카드 픽토그램 적용
- Phosphor Icons (`@phosphor-icons/react`) + 커스텀 SVG FoodIcon 시스템 구축
- `cuisine-icons.ts``getPhosphorCuisineIcon()` 함수 추가 (46개 소분류 매핑)
- `FoodIcon.tsx` 생성 — jjigae, tteok, noodle, tempura, pig 커스텀 SVG 아이콘
- `food:` 접두어로 Phosphor vs 커스텀 SVG 분기 처리
### 지역 필터 추가 + 배포
- 홈 탭에 지역 필터 드롭다운 추가
- v0.1.11로 OKE 배포 완료
---
## 참고: 주의사항
| 항목 | 내용 |
|------|------|
| 새 HTTP 메서드 추가 시 | `WebConfig.java`의 CORS `allowedMethods`에 반드시 추가 |
| 백엔드 코드 수정 후 | `bootJar` 빌드 성공 확인 → `pm2 restart tasteby-api` |
| 프론트엔드 dev 포트 | 3001 고정 (3000은 Gitea) |
| tasteby-web 실행 방식 | `npm run dev` (standalone 아님) |

61
CLAUDE.md Normal file
View File

@@ -0,0 +1,61 @@
# tasteby — Engineering Standards & AI Persona Pipeline
이 파일은 모든 AI 페르소나가 따르는 **단일 진실 기준(SoT)** 이다.
## 0. 필수 참조 (Tasteby 고유)
- 모든 작업 시작 전에 `/skills` 슬래시 커맨드를 실행하거나, `mcp__oracle-26ai-vector__search_similar`로 관련 스킬을 검색하여 적용할 규칙을 확인할 것
- 작업 완료 후 새로운 스킬/지식이 생기면 벡터 스토어에 기록할 것
- 코드 변경 시 `CHANGELOG.md` 업데이트 필수
## 1. 잘 설계된 코드 원칙
- 작게·단일 책임(함수 ≤ ~40줄), I/O 와 순수 로직 분리(테스트 가능성).
- 설정·비밀은 `.env` 에서 주입(하드코딩 금지). 명시적 에러 처리(삼키지 말 것).
- 외부 입력은 경계에서 검증. 의도를 드러내는 네이밍. 주석은 '왜'만.
- 핵심 로직은 테스트 없이 머지 금지. 모든 변경은 Redmine 이슈 ↔ 설계서 ↔ git 커밋으로 연결.
- 디자인 패턴 적용 (메모리의 feedback_design_patterns.md 참조).
## 2. 설계서 우선 (Design-First — 하드 게이트) ⛔
> **설계서 없이는 코드 없음.** 함수가 설계서로 덮이기 전엔 구현하지 않는다.
- Architect 가 구현 전 `docs/design/<issue-id>-<slug>/README.md`(`_TEMPLATE.md`)에 모든 함수를 등재.
- 복잡 함수(분기/상태·외부 I/O·리스크 경로·비자명 알고리즘)는 `fn-<name>.md`(`_FN_TEMPLATE.md`).
- Developer 는 설계서 없으면 구현 거부 → `02-Architect` 로 반려.
- 코드가 설계와 달라지면 **설계서를 먼저** 고친다. 되돌리기 어려운 결정은 ADR(`docs/adr/`).
## 3. 문서 아키텍처
Diátaxis + ADR + 설계서. 지도: `docs/README.md`.
| 종류 | 위치 | 시점 |
|------|------|------|
| 설계서 | `docs/design/` | 구현 전 |
| ADR | `docs/adr/` | 결정 시 |
| 레퍼런스 | `docs/reference/` | 구현 후 |
| 가이드 | `docs/guides/` | 릴리스 시 |
## 4. Git 규율
- 모든 산출물 = git 커밋 + Gitea push("추적 안 된 변경" 금지).
- 커밋: `[<Persona>] #<issue-id> <요약>` ... `Refs #<issue-id>`.
- `.env` 등 비밀 커밋 금지(`.gitignore` 차단).
## 5. AI 페르소나 파이프라인 (완전 자동)
```
[01 Planner]→[02 Architect]→[03 Developer]→[04 QA]→[05 Designer]→[06 Reviewer]→[07 Release]→[08 Documenter]→[09 Done]
(설계서) (설계서 게이트) └──── 반려 ────┘ (QA/Reviewer/설계서누락 시)
```
- 작업 큐 = Redmine 이슈(project `tasteby`). 프로토콜: `docs/pipeline/QUEUE-PROTOCOL.md`.
- 현재 단계 = 이슈 **카테고리**(`01-Planner``09-Done`). 수명주기 = 이슈 **상태**.
- 페르소나: `.claude/agents/`. 오케스트레이터: `.claude/workflows/persona-pipeline.js`.
## 6. 게이트
- 설계서 게이트(02→03), QA 게이트(04), Reviewer 게이트(06). 우회 금지, 반려 사유는 저널 노트.
## 7. 응대 규칙
- 존댓말 사용 (반말 금지).
## 8. 개발 환경 (Tasteby 고유)
- 새 HTTP 메서드 추가 시 `WebConfig.java` CORS allowedMethods 확인.
- 백엔드 코드 수정 후 빌드 성공 확인 → PM2 재시작.
- dev 환경 PM2 설정 변경 금지 (tasteby-web: PORT=3001, tasteby-api: 8000).
## 9. 작업 환경
- Gitea: https://gittea.cloud-handson.com/joungmin/tasteby (branch `main`)
- Redmine: https://redmine.cloud-handson.com/projects/tasteby
- 자격증명은 `.env` 에서 로드.

View File

@@ -28,6 +28,12 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
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)
implementation 'com.oracle.database.jdbc:ojdbc11:23.7.0.25.01'
implementation 'com.oracle.database.security:oraclepki:23.7.0.25.01'

View File

@@ -1,5 +1,6 @@
package com.tasteby;
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@@ -8,6 +9,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableAsync
@EnableScheduling
// #335 — defaultLockAtMostFor: 어떤 작업이 lockAtMostFor 명시 안 해도 보호 (안전 마진)
@EnableSchedulerLock(defaultLockAtMostFor = "PT15M")
public class TastebyApplication {
public static void main(String[] args) {
SpringApplication.run(TastebyApplication.class, args);

View File

@@ -30,13 +30,14 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth
// Public endpoints
.requestMatchers("/api/health").permitAll()
.requestMatchers("/api/version").permitAll() // #338 — 빌드 정보 공개
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/restaurants/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels").permitAll()
.requestMatchers(HttpMethod.GET, "/api/search").permitAll()
.requestMatchers(HttpMethod.GET, "/api/restaurants/*/reviews").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)
.anyRequest().authenticated()
)

View File

@@ -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);
}
}

View File

@@ -22,4 +22,14 @@ public class AdminCacheController {
cacheService.flush();
return Map.of("ok", true);
}
/**
* #336 — 캐시 상태 가시화: disabled / errorCount / lastError.
* 외부 모니터링 도구 도입 전 운영자가 어드민에서 확인 가능.
*/
@GetMapping("/cache/stats")
public CacheService.CacheStats cacheStats() {
AuthUtil.requireAdmin();
return cacheService.getStats();
}
}

View File

@@ -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);
}
}

View File

@@ -1,10 +1,17 @@
package com.tasteby.controller;
import com.tasteby.domain.Memo;
import com.tasteby.domain.Restaurant;
import com.tasteby.domain.Review;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.MemoService;
import com.tasteby.service.ReviewService;
import com.tasteby.service.UserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
@@ -13,18 +20,22 @@ import java.util.Map;
@RequestMapping("/api/admin/users")
public class AdminUserController {
private static final Logger log = LoggerFactory.getLogger(AdminUserController.class);
private final UserService userService;
private final ReviewService reviewService;
private final MemoService memoService;
public AdminUserController(UserService userService, ReviewService reviewService) {
public AdminUserController(UserService userService, ReviewService reviewService, MemoService memoService) {
this.userService = userService;
this.reviewService = reviewService;
this.memoService = memoService;
}
@GetMapping
public Map<String, Object> listUsers(
@RequestParam(defaultValue = "50") int limit,
@RequestParam(defaultValue = "0") int offset) {
AuthUtil.requireAdmin();
var users = userService.findAllWithCounts(limit, offset);
int total = userService.countAll();
return Map.of("users", users, "total", total);
@@ -32,11 +43,32 @@ public class AdminUserController {
@GetMapping("/{userId}/favorites")
public List<Restaurant> userFavorites(@PathVariable String userId) {
AuthUtil.requireAdmin();
return reviewService.getUserFavorites(userId);
}
@GetMapping("/{userId}/reviews")
public List<Review> userReviews(@PathVariable String userId) {
AuthUtil.requireAdmin();
return reviewService.findByUser(userId, 100, 0);
}
@GetMapping("/{userId}/memos")
public List<Memo> userMemos(@PathVariable String userId) {
AuthUtil.requireAdmin();
return memoService.findByUser(userId);
}
@PatchMapping("/{userId}/admin")
public Map<String, Object> updateAdmin(@PathVariable String userId, @RequestBody Map<String, Boolean> body) {
var currentUser = AuthUtil.requireAdmin();
if (userId.equals(currentUser.getSubject())) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "자기 자신의 관리자 권한은 변경할 수 없습니다");
}
boolean admin = Boolean.TRUE.equals(body.get("admin"));
userService.updateAdmin(userId, admin);
log.info("[ADMIN] User {} set admin={} for user {}", currentUser.getSubject(), admin, userId);
return Map.of("success", true, "user_id", userId, "is_admin", admin);
}
}

View File

@@ -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);
}
}

View File

@@ -7,6 +7,7 @@ import com.tasteby.security.AuthUtil;
import com.tasteby.service.CacheService;
import com.tasteby.service.ChannelService;
import com.tasteby.service.YouTubeService;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@@ -52,15 +53,21 @@ public class ChannelController {
String channelId = body.get("channel_id");
String channelName = body.get("channel_name");
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 {
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);
} catch (Exception e) {
if (e.getMessage() != null && e.getMessage().toUpperCase().contains("UQ_CHANNELS_CID")) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Channel already exists");
}
throw e;
} catch (DataIntegrityViolationException e) {
// #295 — 유니크 충돌을 메시지 문자열 매칭 대신 typed 예외로 감지 (제약명 변경에도 견고).
throw new ResponseStatusException(HttpStatus.CONFLICT, "Channel already exists");
}
}
@@ -77,9 +84,10 @@ public class ChannelController {
}
@PutMapping("/{id}")
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, String> body) {
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
AuthUtil.requireAdmin();
channelService.update(id, body.get("description"), body.get("tags"));
Integer sortOrder = body.get("sort_order") != null ? ((Number) body.get("sort_order")).intValue() : null;
channelService.update(id, (String) body.get("description"), (String) body.get("tags"), sortOrder);
cache.flush();
return Map.of("ok", true);
}

View File

@@ -19,6 +19,8 @@ public class DaemonController {
@GetMapping("/config")
public DaemonConfig getConfig() {
// #275 — 데몬 운영 설정은 admin 전용 (이전: 공개 노출 — 정보 누출 위험)
AuthUtil.requireAdmin();
DaemonConfig config = daemonConfigService.getConfig();
return config != null ? config : DaemonConfig.builder().build();
}

View File

@@ -1,5 +1,6 @@
package com.tasteby.controller;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -8,8 +9,20 @@ import java.util.Map;
@RestController
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")
public Map<String, String> health() {
return Map.of("status", "ok");
}
@GetMapping("/api/version")
public Map<String, String> version() {
return Map.of("version", version, "commit", commit);
}
}

View File

@@ -0,0 +1,59 @@
package com.tasteby.controller;
import com.tasteby.domain.Memo;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.MemoService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class MemoController {
private final MemoService memoService;
public MemoController(MemoService memoService) {
this.memoService = memoService;
}
@GetMapping("/restaurants/{restaurantId}/memo")
public Memo getMemo(@PathVariable String restaurantId) {
String userId = AuthUtil.getUserId();
Memo memo = memoService.findByUserAndRestaurant(userId, restaurantId);
if (memo == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No memo");
}
return memo;
}
@PostMapping("/restaurants/{restaurantId}/memo")
public Memo upsertMemo(@PathVariable String restaurantId,
@RequestBody Map<String, Object> body) {
String userId = AuthUtil.getUserId();
Double rating = body.get("rating") != null
? ((Number) body.get("rating")).doubleValue() : null;
String text = (String) body.get("memo_text");
LocalDate visitedAt = body.get("visited_at") != null
? LocalDate.parse((String) body.get("visited_at")) : null;
return memoService.upsert(userId, restaurantId, rating, text, visitedAt);
}
@GetMapping("/users/me/memos")
public List<Memo> myMemos() {
return memoService.findByUser(AuthUtil.getUserId());
}
@DeleteMapping("/restaurants/{restaurantId}/memo")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteMemo(@PathVariable String restaurantId) {
String userId = AuthUtil.getUserId();
if (!memoService.delete(userId, restaurantId)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No memo");
}
}
}

View File

@@ -2,12 +2,15 @@ package com.tasteby.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.microsoft.playwright.*;
import com.tasteby.domain.Restaurant;
import com.tasteby.security.AuthUtil;
import com.tasteby.dto.RestaurantUpdateDTO;
import com.tasteby.service.CacheService;
import com.tasteby.service.GeocodingService;
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.LoggerFactory;
import org.springframework.http.HttpStatus;
@@ -15,12 +18,7 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
@@ -35,13 +33,21 @@ public class RestaurantController {
private final GeocodingService geocodingService;
private final CacheService cache;
private final ObjectMapper objectMapper;
private final WebSearchService webSearch;
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.geocodingService = geocodingService;
this.cache = cache;
this.objectMapper = objectMapper;
this.webSearch = webSearch;
}
// #290 — Bean 종료 시 virtual thread executor를 정리하여 리소스 누수 방지.
@PreDestroy
public void shutdownExecutor() {
executor.shutdown();
}
@GetMapping
@@ -58,7 +64,7 @@ public class RestaurantController {
if (cached != null) {
try {
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);
cache.set(key, result);
@@ -72,7 +78,7 @@ public class RestaurantController {
if (cached != null) {
try {
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);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
@@ -81,14 +87,17 @@ public class RestaurantController {
}
@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();
var r = restaurantService.findById(id);
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
String newName = (String) body.get("name");
String newAddress = (String) body.get("address");
String newName = (String) sanitized.get("name");
String newAddress = (String) sanitized.get("address");
boolean nameChanged = newName != null && !newName.equals(r.getName());
boolean addressChanged = newAddress != null && !newAddress.equals(r.getAddress());
if (nameChanged || addressChanged) {
@@ -96,26 +105,30 @@ public class RestaurantController {
String geoAddr = newAddress != null ? newAddress : r.getAddress();
var geo = geocodingService.geocodeRestaurant(geoName, geoAddr);
if (geo != null) {
body.put("latitude", geo.get("latitude"));
body.put("longitude", geo.get("longitude"));
body.put("google_place_id", geo.get("google_place_id"));
sanitized.put("latitude", geo.get("latitude"));
sanitized.put("longitude", geo.get("longitude"));
sanitized.put("google_place_id", geo.get("google_place_id"));
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_count")) body.put("rating_count", geo.get("rating_count"));
if (geo.containsKey("phone")) body.put("phone", geo.get("phone"));
if (geo.containsKey("business_status")) body.put("business_status", geo.get("business_status"));
if (geo.containsKey("rating")) sanitized.put("rating", geo.get("rating"));
if (geo.containsKey("rating_count")) sanitized.put("rating_count", geo.get("rating_count"));
if (geo.containsKey("phone")) sanitized.put("phone", geo.get("phone"));
if (geo.containsKey("business_status")) sanitized.put("business_status", geo.get("business_status"));
// formatted_address에서 region 파싱 (예: "대한민국 서울특별시 강남구 ..." → "한국|서울|강남구")
String addr = (String) geo.get("formatted_address");
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();
var updated = restaurantService.findById(id);
return Map.of("ok", true, "restaurant", updated);
@@ -139,12 +152,8 @@ public class RestaurantController {
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
try (Playwright pw = Playwright.create()) {
try (Browser browser = launchBrowser(pw)) {
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
return searchTabling(page, r.getName());
}
try {
return searchTabling(r.getName());
} catch (Exception e) {
log.error("[TABLING] Search failed for '{}': {}", r.getName(), e.getMessage());
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
@@ -166,7 +175,7 @@ public class RestaurantController {
@PostMapping("/bulk-tabling")
public SseEmitter bulkTabling() {
AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L);
SseEmitter emitter = new SseEmitter(10_800_000L); // 3h — 대량 백필 대응
executor.execute(() -> {
try {
@@ -183,43 +192,44 @@ public class RestaurantController {
int linked = 0;
int notFound = 0;
try (Playwright pw = Playwright.create()) {
try (Browser browser = launchBrowser(pw)) {
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
for (int i = 0; i < total; i++) {
var r = restaurants.get(i);
emit(emitter, Map.of("type", "processing", "current", i + 1,
"total", total, "name", r.getName()));
for (int i = 0; i < total; i++) {
var r = restaurants.get(i);
emit(emitter, Map.of("type", "processing", "current", i + 1,
"total", total, "name", r.getName()));
try {
var results = searchTabling(page, r.getName());
if (!results.isEmpty()) {
String url = String.valueOf(results.get(0).get("url"));
String title = String.valueOf(results.get(0).get("title"));
restaurantService.update(r.getId(), Map.of("tabling_url", url));
linked++;
emit(emitter, Map.of("type", "done", "current", i + 1,
"name", r.getName(), "url", url, "title", title));
} else {
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
notFound++;
emit(emitter, Map.of("type", "notfound", "current", i + 1,
"name", r.getName()));
}
} catch (Exception e) {
try {
var results = searchTabling(r.getName());
if (!results.isEmpty()) {
String url = String.valueOf(results.get(0).get("url"));
String title = String.valueOf(results.get(0).get("title"));
if (isNameSimilar(r.getName(), title)) {
restaurantService.update(r.getId(), Map.of("tabling_url", url));
linked++;
emit(emitter, Map.of("type", "done", "current", i + 1,
"name", r.getName(), "url", url, "title", title));
} else {
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
notFound++;
emit(emitter, Map.of("type", "error", "current", i + 1,
"name", r.getName(), "message", e.getMessage()));
log.info("[TABLING] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
emit(emitter, Map.of("type", "notfound", "current", i + 1,
"name", r.getName(), "reason", "이름 불일치: " + title));
}
// Google 봇 판정 방지 랜덤 딜레이 (5~15초)
int delay = ThreadLocalRandom.current().nextInt(5000, 15001);
log.info("[TABLING] Waiting {}ms before next search...", delay);
page.waitForTimeout(delay);
} else {
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
notFound++;
emit(emitter, Map.of("type", "notfound", "current", i + 1,
"name", r.getName()));
}
} catch (Exception e) {
notFound++;
emit(emitter, Map.of("type", "error", "current", i + 1,
"name", r.getName(), "message", e.getMessage()));
}
// 랜덤 딜레이 (2~5초)
int delay = ThreadLocalRandom.current().nextInt(2000, 5001);
log.info("[TABLING] Waiting {}ms before next search...", delay);
Thread.sleep(delay);
}
cache.flush();
@@ -241,23 +251,43 @@ public class RestaurantController {
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
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 : ""));
cache.flush();
return Map.of("ok", true);
}
/** 테이블링/캐치테이블 매핑 초기화 */
@DeleteMapping("/reset-tabling")
public Map<String, Object> resetTabling() {
AuthUtil.requireAdmin();
restaurantService.resetTablingUrls();
cache.flush();
return Map.of("ok", true);
}
@DeleteMapping("/reset-catchtable")
public Map<String, Object> resetCatchtable() {
AuthUtil.requireAdmin();
restaurantService.resetCatchtableUrls();
cache.flush();
return Map.of("ok", true);
}
/** 단건 캐치테이블 URL 검색 */
@GetMapping("/{id}/catchtable-search")
public List<Map<String, Object>> catchtableSearch(@PathVariable String id) {
AuthUtil.requireAdmin();
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
try (Playwright pw = Playwright.create()) {
try (Browser browser = launchBrowser(pw)) {
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
return searchCatchtable(page, r.getName());
}
try {
return searchCatchtable(r.getName());
} catch (Exception e) {
log.error("[CATCHTABLE] Search failed for '{}': {}", r.getName(), e.getMessage());
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
@@ -279,7 +309,7 @@ public class RestaurantController {
@PostMapping("/bulk-catchtable")
public SseEmitter bulkCatchtable() {
AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L);
SseEmitter emitter = new SseEmitter(10_800_000L); // 3h — 대량 백필 대응
executor.execute(() -> {
try {
@@ -296,42 +326,43 @@ public class RestaurantController {
int linked = 0;
int notFound = 0;
try (Playwright pw = Playwright.create()) {
try (Browser browser = launchBrowser(pw)) {
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
for (int i = 0; i < total; i++) {
var r = restaurants.get(i);
emit(emitter, Map.of("type", "processing", "current", i + 1,
"total", total, "name", r.getName()));
for (int i = 0; i < total; i++) {
var r = restaurants.get(i);
emit(emitter, Map.of("type", "processing", "current", i + 1,
"total", total, "name", r.getName()));
try {
var results = searchCatchtable(page, r.getName());
if (!results.isEmpty()) {
String url = String.valueOf(results.get(0).get("url"));
String title = String.valueOf(results.get(0).get("title"));
restaurantService.update(r.getId(), Map.of("catchtable_url", url));
linked++;
emit(emitter, Map.of("type", "done", "current", i + 1,
"name", r.getName(), "url", url, "title", title));
} else {
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
notFound++;
emit(emitter, Map.of("type", "notfound", "current", i + 1,
"name", r.getName()));
}
} catch (Exception e) {
try {
var results = searchCatchtable(r.getName());
if (!results.isEmpty()) {
String url = String.valueOf(results.get(0).get("url"));
String title = String.valueOf(results.get(0).get("title"));
if (isNameSimilar(r.getName(), title)) {
restaurantService.update(r.getId(), Map.of("catchtable_url", url));
linked++;
emit(emitter, Map.of("type", "done", "current", i + 1,
"name", r.getName(), "url", url, "title", title));
} else {
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
notFound++;
emit(emitter, Map.of("type", "error", "current", i + 1,
"name", r.getName(), "message", e.getMessage()));
log.info("[CATCHTABLE] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
emit(emitter, Map.of("type", "notfound", "current", i + 1,
"name", r.getName(), "reason", "이름 불일치: " + title));
}
int delay = ThreadLocalRandom.current().nextInt(5000, 15001);
log.info("[CATCHTABLE] Waiting {}ms before next search...", delay);
page.waitForTimeout(delay);
} else {
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
notFound++;
emit(emitter, Map.of("type", "notfound", "current", i + 1,
"name", r.getName()));
}
} catch (Exception e) {
notFound++;
emit(emitter, Map.of("type", "error", "current", i + 1,
"name", r.getName(), "message", e.getMessage()));
}
int delay = ThreadLocalRandom.current().nextInt(2000, 5001);
log.info("[CATCHTABLE] Waiting {}ms before next search...", delay);
Thread.sleep(delay);
}
cache.flush();
@@ -353,140 +384,62 @@ public class RestaurantController {
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
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 : ""));
cache.flush();
return Map.of("ok", true);
}
@GetMapping("/{id}/videos")
public List<Map<String, Object>> videos(@PathVariable String id) {
String key = cache.makeKey("restaurant_videos", id);
public List<Map<String, Object>> videos(
@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);
if (cached != null) {
try {
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);
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);
return result;
}
// ─── Playwright helpers ──────────────────────────────────────────────
// ─── 예약 사이트 URL 검색 (#357 WebSearchService: Naver primary + DDG fallback) ───
private Browser launchBrowser(Playwright pw) {
return pw.chromium().launch(new BrowserType.LaunchOptions()
.setHeadless(false)
.setArgs(List.of("--disable-blink-features=AutomationControlled")));
private List<Map<String, Object>> searchTabling(String restaurantName) {
return webSearch.search(
"site:tabling.co.kr " + restaurantName,
"tabling.co.kr/restaurant/", "tabling.co.kr/place/"
);
}
private BrowserContext newContext(Browser browser) {
return browser.newContext(new Browser.NewContextOptions()
.setLocale("ko-KR").setViewportSize(1280, 900));
private List<Map<String, Object>> searchCatchtable(String restaurantName) {
// 실제 캐치테이블 URL은 /ct/shop/ 형식. 옛 /dining/ /shop/ 패턴은 매칭 실패.
return webSearch.search(
"site:app.catchtable.co.kr " + restaurantName,
"catchtable.co.kr/ct/shop/", "catchtable.co.kr/ct/dining/"
);
}
private Page newPage(BrowserContext ctx) {
Page page = ctx.newPage();
page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})");
return page;
}
@SuppressWarnings("unchecked")
private List<Map<String, Object>> searchTabling(Page page, String restaurantName) {
String query = "site:tabling.co.kr " + restaurantName;
log.info("[TABLING] Searching: {}", query);
String searchUrl = "https://www.google.com/search?q=" +
URLEncoder.encode(query, StandardCharsets.UTF_8);
page.navigate(searchUrl);
page.waitForTimeout(3000);
Object linksObj = page.evaluate("""
() => {
const results = [];
const links = document.querySelectorAll('a[href]');
for (const a of links) {
const href = a.href;
if (href.includes('tabling.co.kr/restaurant/') || href.includes('tabling.co.kr/place/')) {
const title = a.closest('[data-header-feature]')?.querySelector('h3')?.textContent
|| a.querySelector('h3')?.textContent
|| a.textContent?.trim()?.substring(0, 80)
|| '';
results.push({ title, url: href });
}
}
const seen = new Set();
return results.filter(r => {
if (seen.has(r.url)) return false;
seen.add(r.url);
return true;
}).slice(0, 5);
}
""");
List<Map<String, Object>> results = new ArrayList<>();
if (linksObj instanceof List<?> list) {
for (var item : list) {
if (item instanceof Map<?, ?> map) {
results.add(Map.of(
"title", String.valueOf(map.get("title")),
"url", String.valueOf(map.get("url"))
));
}
}
}
log.info("[TABLING] Found {} results for '{}'", results.size(), restaurantName);
return results;
}
@SuppressWarnings("unchecked")
private List<Map<String, Object>> searchCatchtable(Page page, String restaurantName) {
String query = "site:app.catchtable.co.kr " + restaurantName;
log.info("[CATCHTABLE] Searching: {}", query);
String searchUrl = "https://www.google.com/search?q=" +
URLEncoder.encode(query, StandardCharsets.UTF_8);
page.navigate(searchUrl);
page.waitForTimeout(3000);
Object linksObj = page.evaluate("""
() => {
const results = [];
const links = document.querySelectorAll('a[href]');
for (const a of links) {
const href = a.href;
if (href.includes('catchtable.co.kr/') && (href.includes('/dining/') || href.includes('/shop/'))) {
const title = a.closest('[data-header-feature]')?.querySelector('h3')?.textContent
|| a.querySelector('h3')?.textContent
|| a.textContent?.trim()?.substring(0, 80)
|| '';
results.push({ title, url: href });
}
}
const seen = new Set();
return results.filter(r => {
if (seen.has(r.url)) return false;
seen.add(r.url);
return true;
}).slice(0, 5);
}
""");
List<Map<String, Object>> results = new ArrayList<>();
if (linksObj instanceof List<?> list) {
for (var item : list) {
if (item instanceof Map<?, ?> map) {
results.add(Map.of(
"title", String.valueOf(map.get("title")),
"url", String.valueOf(map.get("url"))
));
}
}
}
log.info("[CATCHTABLE] Found {} results for '{}'", results.size(), restaurantName);
return results;
/**
* 식당 이름과 검색 결과 제목의 유사도 검사.
* 한쪽 이름이 다른쪽에 포함되거나, 공통 글자 비율이 40% 이상이면 유사하다고 판단.
*/
/**
* #348 — 한국어 자모 분해 + Sørensen-Dice bigram 유사도(임계값 0.45).
* 짧은 한국어 이름에서 이전 Jaccard-like(set 비율) 방식보다 정확.
*/
private boolean isNameSimilar(String restaurantName, String resultTitle) {
return com.tasteby.util.HangulSimilarity.similarity(restaurantName, resultTitle) >= 0.45;
}
private void emit(SseEmitter emitter, Map<String, Object> data) {

View File

@@ -39,7 +39,7 @@ public class ReviewController {
@PathVariable String restaurantId,
@RequestBody Map<String, Object> body) {
String userId = AuthUtil.getUserId();
double rating = ((Number) body.get("rating")).doubleValue();
double rating = requireRating(body.get("rating"));
String text = (String) body.get("review_text");
LocalDate visitedAt = body.get("visited_at") != null
? LocalDate.parse((String) body.get("visited_at")) : null;
@@ -51,8 +51,7 @@ public class ReviewController {
@PathVariable String reviewId,
@RequestBody Map<String, Object> body) {
String userId = AuthUtil.getUserId();
Double rating = body.get("rating") != null
? ((Number) body.get("rating")).doubleValue() : null;
Double rating = body.get("rating") != null ? requireRating(body.get("rating")) : null;
String text = (String) body.get("review_text");
LocalDate visitedAt = body.get("visited_at") != null
? LocalDate.parse((String) body.get("visited_at")) : null;
@@ -94,4 +93,18 @@ public class ReviewController {
public List<Restaurant> myFavorites() {
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;
}
}

View File

@@ -2,7 +2,9 @@ package com.tasteby.controller;
import com.tasteby.domain.Restaurant;
import com.tasteby.service.SearchService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@@ -21,7 +23,12 @@ public class SearchController {
@RequestParam String q,
@RequestParam(defaultValue = "keyword") String mode,
@RequestParam(defaultValue = "20") int limit) {
// #293 — q 빈값 가드: '%%' LIKE로 응답 폭발 차단
if (q == null || q.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "검색어가 필요합니다");
}
if (limit > 100) limit = 100;
return searchService.search(q, mode, limit);
if (limit < 1) limit = 1;
return searchService.search(q.trim(), mode, limit);
}
}

View File

@@ -1,7 +1,12 @@
package com.tasteby.controller;
import com.tasteby.domain.SiteVisitStats;
import com.tasteby.service.RateLimitService;
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 java.util.Map;
@@ -10,20 +15,51 @@ import java.util.Map;
@RequestMapping("/api/stats")
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.rateLimitService = rateLimitService;
}
@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();
return Map.of("ok", true);
return Map.of("ok", true, "counted", true);
}
@GetMapping("/visits")
public SiteVisitStats 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";
}
}

View File

@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
@@ -182,7 +183,8 @@ public class VideoSseController {
for (int i = 0; i < total; i++) {
var v = rows.get(i);
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));
Thread.sleep(delay);
}
@@ -347,13 +349,15 @@ public class VideoSseController {
@PostMapping("/rebuild-vectors")
public SseEmitter rebuildVectors() {
AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L);
SseEmitter emitter = new SseEmitter(60_000L);
executor.execute(() -> {
try {
emit(emitter, Map.of("type", "start"));
// TODO: Implement full vector rebuild using VectorService
emit(emitter, Map.of("type", "complete", "total", 0));
// #325 — 운영자에게 미구현 상태 명시 (이전: 즉시 complete(total=0) → 무반응 인상)
emit(emitter, Map.of(
"type", "not_implemented",
"message", "벡터 재생성은 아직 구현되지 않았습니다. 후속 이슈(#325/#331)에서 처리 예정입니다."
));
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);

View File

@@ -16,6 +16,7 @@ public class Channel {
private String titleFilter;
private String description;
private String tags;
private Integer sortOrder;
private int videoCount;
private String lastVideoAt;
}

View File

@@ -0,0 +1,22 @@
package com.tasteby.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Memo {
private String id;
private String userId;
private String restaurantId;
private Double rating;
private String memoText;
private String visitedAt;
private String createdAt;
private String updatedAt;
private String restaurantName;
}

View File

@@ -31,6 +31,11 @@ public class Restaurant {
private Integer ratingCount;
private Date updatedAt;
// #322 LLM 검증
private Boolean hidden;
private String hiddenReason;
private Date verifiedAt;
// Transient enrichment fields
private List<String> channels;
private List<String> foodsMentioned;

View File

@@ -10,6 +10,7 @@ import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
public class SiteVisitStats {
private int today;
private int total;
// #274 — long으로 변경 (21억 이상 누적 시 int 오버플로 방지)
private long today;
private long total;
}

View File

@@ -22,4 +22,5 @@ public class UserInfo {
private String createdAt;
private int favoriteCount;
private int reviewCount;
private int memoCount;
}

View File

@@ -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;
}
}

View File

@@ -22,7 +22,8 @@ public interface ChannelMapper {
Channel findByChannelId(@Param("channelId") String channelId);
void updateDescriptionTags(@Param("id") String id,
@Param("description") String description,
@Param("tags") String tags);
void updateChannel(@Param("id") String id,
@Param("description") String description,
@Param("tags") String tags,
@Param("sortOrder") Integer sortOrder);
}

View File

@@ -0,0 +1,32 @@
package com.tasteby.mapper;
import com.tasteby.domain.Memo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface MemoMapper {
Memo findByUserAndRestaurant(@Param("userId") String userId,
@Param("restaurantId") String restaurantId);
void insertMemo(@Param("id") String id,
@Param("userId") String userId,
@Param("restaurantId") String restaurantId,
@Param("rating") Double rating,
@Param("memoText") String memoText,
@Param("visitedAt") String visitedAt);
int updateMemo(@Param("userId") String userId,
@Param("restaurantId") String restaurantId,
@Param("rating") Double rating,
@Param("memoText") String memoText,
@Param("visitedAt") String visitedAt);
int deleteMemo(@Param("userId") String userId,
@Param("restaurantId") String restaurantId);
List<Memo> findByUser(@Param("userId") String userId);
}

View File

@@ -14,11 +14,38 @@ public interface RestaurantMapper {
@Param("offset") int offset,
@Param("cuisine") String cuisine,
@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);
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);
@@ -59,6 +86,10 @@ public interface RestaurantMapper {
List<Restaurant> findWithoutCatchtable();
void resetTablingUrls();
void resetCatchtableUrls();
List<Map<String, Object>> findForRemapCuisine();
List<Map<String, Object>> findForRemapFoods();

View File

@@ -7,7 +7,7 @@ public interface StatsMapper {
void recordVisit();
int getTodayVisits();
long getTodayVisits();
int getTotalVisits();
long getTotalVisits();
}

View File

@@ -6,6 +6,8 @@ import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.gson.GsonFactory;
import com.tasteby.domain.UserInfo;
import com.tasteby.security.JwtTokenProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -17,6 +19,8 @@ import java.util.Map;
@Service
public class AuthService {
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
private final UserService userService;
private final JwtTokenProvider jwtProvider;
private final GoogleIdTokenVerifier verifier;
@@ -58,7 +62,10 @@ public class AuthService {
} catch (ResponseStatusException e) {
throw 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");
}
}

View File

@@ -5,38 +5,52 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Set;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
@Service
public class CacheService {
private static final Logger log = LoggerFactory.getLogger(CacheService.class);
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 ObjectMapper mapper;
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,
@Value("${app.cache.ttl-seconds:600}") int ttlSeconds) {
this.redis = redis;
this.mapper = mapper;
this.ttl = Duration.ofSeconds(ttlSeconds);
try {
redis.getConnectionFactory().getConnection().ping();
log.info("Redis connected");
} catch (Exception e) {
log.warn("Redis unavailable ({}), caching disabled", e.getMessage());
disabled = true;
}
this.disabled = !pingOk();
if (!disabled) log.info("Redis connected");
}
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);
}
@@ -48,7 +62,7 @@ public class CacheService {
return mapper.readValue(val, type);
}
} catch (Exception e) {
log.debug("Cache get error: {}", e.getMessage());
recordError("get", e);
}
return null;
}
@@ -58,7 +72,7 @@ public class CacheService {
try {
return redis.opsForValue().get(key);
} catch (Exception e) {
log.debug("Cache get error: {}", e.getMessage());
recordError("getRaw", e);
return null;
}
}
@@ -69,20 +83,114 @@ public class CacheService {
String json = mapper.writeValueAsString(value);
redis.opsForValue().set(key, json, ttl);
} 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() {
if (disabled) return;
try {
Set<String> keys = redis.keys(PREFIX + "*");
if (keys != null && !keys.isEmpty()) {
redis.delete(keys);
Integer count = redis.execute((org.springframework.data.redis.core.RedisCallback<Integer>) conn -> {
List<byte[]> batch = new ArrayList<>(SCAN_BATCH);
int deleted = 0;
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) {
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) {}
}

View File

@@ -27,11 +27,16 @@ public class ChannelService {
}
public boolean deactivate(String channelId) {
// Try deactivate by channel_id first, then by DB id
int rows = mapper.deactivateByChannelId(channelId);
if (rows == 0) {
rows = mapper.deactivateById(channelId);
}
if (channelId == null || channelId.isBlank()) return false;
// #295 — 입력 형식으로 명시적 분기:
// "UC..."(24 chars) 형식 → YouTube channel_id로 비활성화
// 그 외(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;
}
@@ -39,7 +44,7 @@ public class ChannelService {
return mapper.findByChannelId(channelId);
}
public void update(String id, String description, String tags) {
mapper.updateDescriptionTags(id, description, tags);
public void update(String id, String description, String tags, Integer sortOrder) {
mapper.updateChannel(id, description, tags, sortOrder);
}
}

View File

@@ -2,7 +2,9 @@ package com.tasteby.service;
import com.tasteby.domain.DaemonConfig;
import com.tasteby.mapper.DaemonConfigMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
@@ -27,20 +29,33 @@ public class DaemonConfigService {
current.setScanEnabled(Boolean.TRUE.equals(body.get("scan_enabled")));
}
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")) {
current.setProcessEnabled(Boolean.TRUE.equals(body.get("process_enabled")));
}
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")) {
current.setProcessLimit(((Number) body.get("process_limit")).intValue());
current.setProcessLimit(requirePositiveInt(body.get("process_limit"), "process_limit"));
}
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() {
mapper.updateLastScan();
}

View File

@@ -1,8 +1,10 @@
package com.tasteby.service;
import com.tasteby.domain.DaemonConfig;
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@@ -22,6 +24,9 @@ public class DaemonScheduler {
private final PipelineService pipelineService;
private final CacheService cacheService;
@Value("${app.daemon.enabled:true}")
private boolean instanceEnabled;
public DaemonScheduler(DaemonConfigService daemonConfigService,
YouTubeService youTubeService,
PipelineService pipelineService,
@@ -33,7 +38,15 @@ public class DaemonScheduler {
}
@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() {
// 인스턴스 차원 차단(dev/prod 동일 DB 공유 환경에서 dev 쪽 동시 폴링 방지).
// dev .env: DAEMON_ENABLED=false → 이 인스턴스는 스케줄러 동작 안 함.
// prod: 미설정 → 기본 true.
if (!instanceEnabled) return;
try {
var config = getConfig();
if (config == null) return;
@@ -42,8 +55,13 @@ public class DaemonScheduler {
Instant lastScan = config.getLastScanAt() != null ? config.getLastScanAt().toInstant() : null;
if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.getScanIntervalMin(), ChronoUnit.MINUTES))) {
log.info("Running scheduled channel scan...");
int newVideos = youTubeService.scanAllChannels();
daemonConfigService.updateLastScan();
int newVideos = 0;
try {
newVideos = youTubeService.scanAllChannels();
} finally {
// #275 — 외부 호출 예외 시에도 last_scan_at을 갱신해 다음 cron까지의 backoff를 보장
daemonConfigService.updateLastScan();
}
if (newVideos > 0) {
cacheService.flush();
log.info("Scan completed: {} new videos", newVideos);
@@ -55,8 +73,12 @@ public class DaemonScheduler {
Instant lastProcess = config.getLastProcessAt() != null ? config.getLastProcessAt().toInstant() : null;
if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.getProcessIntervalMin(), ChronoUnit.MINUTES))) {
log.info("Running scheduled video processing (limit={})...", config.getProcessLimit());
int restaurants = pipelineService.processPending(config.getProcessLimit());
daemonConfigService.updateLastProcess();
int restaurants = 0;
try {
restaurants = pipelineService.processPending(config.getProcessLimit());
} finally {
daemonConfigService.updateLastProcess();
}
if (restaurants > 0) {
cacheService.flush();
log.info("Processing completed: {} restaurants extracted", restaurants);

View File

@@ -38,7 +38,7 @@ public class ExtractorService {
%s
- price_range: 가격대 (예: 1만원대, 2-3만원) (string | null)
- foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성)
- evaluation: 평가 내용 (string | null)
- evaluation: 평가 내용을 100자 이내로 요약 (string | null)
- guests: 함께한 게스트 (string[])
영상 제목: {title}
@@ -62,6 +62,10 @@ public class ExtractorService {
*/
@SuppressWarnings("unchecked")
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
if (transcript.length() > 8000) {
transcript = transcript.substring(0, 7000) + "\n...(중략)...\n" + transcript.substring(transcript.length() - 1000);

View File

@@ -156,7 +156,15 @@ public class GeocodingService {
if (country.isEmpty() && !city.isEmpty()) country = "한국";
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) {

View File

@@ -0,0 +1,52 @@
package com.tasteby.service;
import com.tasteby.domain.Memo;
import com.tasteby.mapper.MemoMapper;
import com.tasteby.util.IdGenerator;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
@Service
public class MemoService {
private final MemoMapper mapper;
public MemoService(MemoMapper mapper) {
this.mapper = mapper;
}
public Memo findByUserAndRestaurant(String userId, String restaurantId) {
return mapper.findByUserAndRestaurant(userId, restaurantId);
}
@Transactional
public Memo upsert(String userId, String restaurantId, Double rating, String memoText, LocalDate visitedAt) {
String visitedStr = visitedAt != null ? visitedAt.toString() : null;
// #294 — 동시성 가드: 사전 SELECT → 분기 INSERT/UPDATE 패턴은 두 트랜잭션이 동시에 미존재
// 판정 후 둘 다 INSERT → UNIQUE 충돌(500). INSERT 우선 시도 후 DuplicateKeyException 시 UPDATE.
Memo existing = mapper.findByUserAndRestaurant(userId, restaurantId);
if (existing != null) {
mapper.updateMemo(userId, restaurantId, rating, memoText, visitedStr);
} else {
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);
}
public boolean delete(String userId, String restaurantId) {
return mapper.deleteMemo(userId, restaurantId) > 0;
}
public List<Memo> findByUser(String userId) {
return mapper.findByUser(userId);
}
}

View File

@@ -150,26 +150,25 @@ public class OciGenAiService {
return mapper.readValue(raw, Object.class);
} 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("[")) {
List<Object> items = new ArrayList<>();
int idx = raw.indexOf('[') + 1;
while (idx < raw.length()) {
while (idx < raw.length() && " \t\n\r,".indexOf(raw.charAt(idx)) >= 0) idx++;
if (idx >= raw.length() || raw.charAt(idx) == ']') break;
if (raw.charAt(idx) != '{') break; // 객체 시작이 아니면 복구 중단
// Try to parse next object
boolean found = false;
for (int end = idx + 1; end <= raw.length(); end++) {
try {
Object obj = mapper.readValue(raw.substring(idx, end), Object.class);
items.add(obj);
idx = end;
found = true;
break;
} catch (Exception ignored2) {}
int end = findObjectEnd(raw, idx);
if (end < 0) break; // 잘린 객체 — 거기서 멈춤
try {
Object obj = mapper.readValue(raw.substring(idx, end + 1), Object.class);
items.add(obj);
} catch (Exception ignored2) {
break; // 불가해 객체 — 멈춤
}
if (!found) break;
idx = end + 1;
}
if (!items.isEmpty()) {
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())));
}
/**
* #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;
}
}

View File

@@ -28,6 +28,8 @@ public class PipelineService {
private final VideoService videoService;
private final VectorService vectorService;
private final CacheService cacheService;
private final RestaurantVerifyService verifyService;
private final VideoRelevanceService relevanceService;
public PipelineService(YouTubeService youTubeService,
ExtractorService extractorService,
@@ -35,7 +37,9 @@ public class PipelineService {
RestaurantService restaurantService,
VideoService videoService,
VectorService vectorService,
CacheService cacheService) {
CacheService cacheService,
RestaurantVerifyService verifyService,
VideoRelevanceService relevanceService) {
this.youTubeService = youTubeService;
this.extractorService = extractorService;
this.geocodingService = geocodingService;
@@ -43,6 +47,8 @@ public class PipelineService {
this.videoService = videoService;
this.vectorService = vectorService;
this.cacheService = cacheService;
this.verifyService = verifyService;
this.relevanceService = relevanceService;
}
/**
@@ -84,6 +90,9 @@ public class PipelineService {
String videoDbId = (String) video.get("id");
String title = (String) video.get("title");
// #292 — 외부 가시성을 위해 진입 시 processing 전이 (이미 processing이면 no-op)
updateVideoStatus(videoDbId, "processing", null, null);
var result = extractorService.extractRestaurants(title, transcript, customPrompt);
if (result.restaurants().isEmpty()) {
updateVideoStatus(videoDbId, "done", null, result.rawResponse());
@@ -102,18 +111,26 @@ public class PipelineService {
// Build upsert data
var data = new HashMap<String, Object>();
data.put("name", name);
data.put("address", geo != null ? geo.get("formatted_address") : restData.get("address"));
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("price_range", restData.get("price_range"));
data.put("google_place_id", geo != null ? geo.get("google_place_id") : null);
data.put("phone", geo != null ? geo.get("phone") : null);
data.put("website", geo != null ? geo.get("website") : null);
data.put("business_status", geo != null ? geo.get("business_status") : null);
data.put("rating", geo != null ? geo.get("rating") : null);
data.put("rating_count", geo != null ? geo.get("rating_count") : null);
// #292 — geocode 실패(geo==null) 시 좌표/주소/place_id 등 기존 값 보존하기 위해
// null을 명시적으로 put하지 않는다. upsert 측에서 누락 컬럼은 그대로 유지.
if (geo != null) {
data.put("address", geo.get("formatted_address"));
data.put("latitude", geo.get("latitude"));
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);
@@ -131,13 +148,16 @@ public class PipelineService {
evaluationJson = JsonUtil.toJson(s);
}
restaurantService.linkVideoRestaurant(
String linkId = restaurantService.linkVideoRestaurant(
videoDbId, restId,
foods instanceof List<?> ? (List<String>) foods : null,
evaluationJson,
guests instanceof List<?> ? (List<String>) guests : null
);
// #356 — 신규 등록 직후 비동기 관련도 평가
relevanceService.verifyAsync(linkId);
// Vector embeddings
var chunks = VectorService.buildChunks(name, restData, title);
if (!chunks.isEmpty()) {
@@ -150,6 +170,9 @@ public class PipelineService {
count++;
log.info("Saved restaurant: {} (geocoded={})", name, geo != null);
// #322 — 등록 직후 비동기 LLM 검증
verifyService.verifyAsync(restId);
}
updateVideoStatus(videoDbId, "done", null, result.rawResponse());

View File

@@ -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;
}
}
}

View File

@@ -21,11 +21,36 @@ public class RestaurantService {
}
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);
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() {
return mapper.findWithoutTabling();
}
@@ -34,6 +59,16 @@ public class RestaurantService {
return mapper.findWithoutCatchtable();
}
@Transactional
public void resetTablingUrls() {
mapper.resetTablingUrls();
}
@Transactional
public void resetCatchtableUrls() {
mapper.resetCatchtableUrls();
}
public Restaurant findById(String id) {
Restaurant restaurant = mapper.findById(id);
if (restaurant == null) return null;
@@ -42,7 +77,11 @@ public class RestaurantService {
}
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 -> {
var m = JsonUtil.lowerKeys(row);
m.put("foods_mentioned", JsonUtil.parseStringList(m.get("foods_mentioned")));
@@ -52,6 +91,43 @@ public class RestaurantService {
}).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) {
mapper.updateFields(id, fields);
}
@@ -103,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 foodsJson = foods != null ? JsonUtil.toJson(foods) : 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) {

View File

@@ -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"); }
}
}

View File

@@ -5,6 +5,7 @@ import com.tasteby.domain.Review;
import com.tasteby.mapper.ReviewMapper;
import com.tasteby.util.IdGenerator;
import com.tasteby.util.JsonUtil;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -37,11 +38,13 @@ public class ReviewService {
return mapper.findById(id);
}
@Transactional // #334 — 단일 SQL이지만 어노테이션 일관성
public boolean update(String reviewId, String userId, Double rating, String reviewText, LocalDate visitedAt) {
String visitedStr = visitedAt != null ? visitedAt.toString() : null;
return mapper.updateReview(reviewId, userId, rating, reviewText, visitedStr) > 0;
}
@Transactional // #334 — 단일 SQL이지만 어노테이션 일관성
public boolean delete(String reviewId, String userId) {
return mapper.deleteReview(reviewId, userId) > 0;
}
@@ -60,10 +63,15 @@ public class ReviewService {
if (existingId != null) {
mapper.deleteFavorite(userId, restaurantId);
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) {

View File

@@ -1,9 +1,12 @@
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.mapper.SearchMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.*;
@@ -12,12 +15,17 @@ import java.util.*;
public class SearchService {
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 RestaurantService restaurantService;
private final VectorService vectorService;
private final CacheService cache;
@Value("${app.search.max-distance:0.57}")
private double maxDistance;
public SearchService(SearchMapper searchMapper,
RestaurantService restaurantService,
VectorService vectorService,
@@ -33,8 +41,8 @@ public class SearchService {
String cached = cache.getRaw(key);
if (cached != null) {
try {
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
return mapper.readValue(cached, new com.fasterxml.jackson.core.type.TypeReference<List<Restaurant>>() {});
// #293 — ObjectMapper 재사용 (필드 static)
return JSON.readValue(cached, LIST_TYPE);
} catch (Exception ignored) {}
}
@@ -44,13 +52,20 @@ public class SearchService {
case "hybrid" -> {
var kw = keywordSearch(q, limit);
var sem = semanticSearch(q, limit);
// #293 — semantic 결과에도 channels 부착 (이전: keyword에만 부착되어 hybrid에서 sem 결과는 채널 누락)
if (!sem.isEmpty()) attachChannels(sem);
Set<String> seen = new HashSet<>();
var merged = new ArrayList<Restaurant>();
for (var r : kw) { 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;
}
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);
@@ -58,7 +73,10 @@ public class SearchService {
}
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);
if (!results.isEmpty()) {
attachChannels(results);
@@ -68,7 +86,7 @@ public class SearchService {
private List<Restaurant> semanticSearch(String q, int limit) {
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();
Set<String> seen = new LinkedHashSet<>();

View File

@@ -2,11 +2,16 @@ package com.tasteby.service;
import com.tasteby.domain.SiteVisitStats;
import com.tasteby.mapper.StatsMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
@Service
public class StatsService {
private static final Logger log = LoggerFactory.getLogger(StatsService.class);
private final StatsMapper mapper;
public StatsService(StatsMapper mapper) {
@@ -14,7 +19,19 @@ public class StatsService {
}
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() {

View File

@@ -1,10 +1,12 @@
package com.tasteby.service;
import com.tasteby.util.IdGenerator;
import com.tasteby.util.JsonUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import org.springframework.stereotype.Service;
import java.util.*;
@@ -27,12 +29,15 @@ public class VectorService {
*/
public List<Map<String, Object>> searchSimilar(String query, int topK, double maxDistance) {
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
float[] queryVec = new float[embeddings.getFirst().size()];
float[] queryVec = new float[first.size()];
for (int i = 0; i < queryVec.length; i++) {
queryVec[i] = embeddings.getFirst().get(i).floatValue();
queryVec[i] = first.get(i).floatValue();
}
String sql = """
@@ -61,6 +66,9 @@ public class VectorService {
/**
* 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) {
if (chunks.isEmpty()) return;
@@ -72,19 +80,20 @@ public class VectorService {
VALUES (:id, :rid, :chunk, :emb)
""";
SqlParameterSource[] batch = new SqlParameterSource[chunks.size()];
for (int i = 0; i < chunks.size(); i++) {
String id = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase();
float[] vec = new float[embeddings.get(i).size()];
List<Double> emb = embeddings.get(i);
float[] vec = new float[emb.size()];
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();
params.addValue("id", id);
params.addValue("rid", restaurantId);
params.addValue("chunk", chunks.get(i));
params.addValue("emb", vec);
jdbc.update(sql, params);
batch[i] = new MapSqlParameterSource()
.addValue("id", IdGenerator.newId())
.addValue("rid", restaurantId)
.addValue("chunk", chunks.get(i))
.addValue("emb", vec);
}
jdbc.batchUpdate(sql, batch);
}
/**

View File

@@ -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"); }
}
}

View File

@@ -28,6 +28,9 @@ public class VideoService {
VideoDetail detail = mapper.findDetail(id);
if (detail == null) return null;
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());
return detail;
}
@@ -59,6 +62,7 @@ public class VideoService {
mapper.cleanupOrphanRestaurant(restaurantId);
}
@Transactional
public int saveVideosBatch(String channelId, List<Map<String, Object>> videos) {
Set<String> existing = new HashSet<>(mapper.getExistingVideoIds(channelId));
int saved = 0;

View File

@@ -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;
}
}

View File

@@ -59,6 +59,7 @@ public class YouTubeService {
String uploadsPlaylistId = "UU" + channelId.substring(2);
List<Map<String, Object>> allVideos = new ArrayList<>();
String nextPage = null;
boolean stopPaging = false;
try {
do {
@@ -88,7 +89,7 @@ public class YouTubeService {
// publishedAfter 필터: 이미 스캔한 영상 이후만
if (publishedAfter != null && publishedAt.compareTo(publishedAfter) <= 0) {
// 업로드 재생목록은 최신순이므로 이전 날짜 만나면 중단
nextPage = null;
stopPaging = true;
break;
}
@@ -105,7 +106,9 @@ public class YouTubeService {
}
allVideos.addAll(pageVideos);
if (nextPage != null || data.has("nextPageToken")) {
if (stopPaging) {
nextPage = null;
} else {
nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null;
}
} while (nextPage != null);
@@ -275,8 +278,19 @@ public class YouTubeService {
/**
* 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) {
if (mode == null) mode = "auto";

View 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();
}
}

View File

@@ -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("[\\\\-_()\\[\\]【】]", "").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;
}
}

View File

@@ -56,9 +56,33 @@ app:
youtube-api-key: ${YOUTUBE_DATA_API_KEY}
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:
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:
# 인스턴스 차원 스케줄러 활성화. dev/prod가 같은 DB를 공유하므로
# dev .env에 DAEMON_ENABLED=false를 설정해 dev 폴링을 끄고 prod만 동작시킨다.
enabled: ${DAEMON_ENABLED:true}
mybatis:
mapper-locations: classpath:mybatis/mapper/*.xml
config-location: classpath:mybatis/mybatis-config.xml

View File

@@ -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);

View File

@@ -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);

View File

@@ -9,17 +9,18 @@
<result property="titleFilter" column="title_filter"/>
<result property="description" column="description"/>
<result property="tags" column="tags"/>
<result property="sortOrder" column="sort_order"/>
<result property="videoCount" column="video_count"/>
<result property="lastVideoAt" column="last_video_at"/>
</resultMap>
<select id="findAllActive" resultMap="channelResultMap">
SELECT c.id, c.channel_id, c.channel_name, c.title_filter, c.description, c.tags,
SELECT c.id, c.channel_id, c.channel_name, c.title_filter, c.description, c.tags, c.sort_order,
(SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id) AS video_count,
(SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_video_at
FROM channels c
WHERE c.is_active = 1
ORDER BY c.channel_name
ORDER BY c.sort_order, c.channel_name
</select>
<insert id="insert">
@@ -37,13 +38,14 @@
WHERE id = #{id} AND is_active = 1
</update>
<update id="updateDescriptionTags">
UPDATE channels SET description = #{description}, tags = #{tags}
<update id="updateChannel">
UPDATE channels SET description = #{description}, tags = #{tags}, sort_order = #{sortOrder}
WHERE id = #{id}
</update>
<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
WHERE channel_id = #{channelId} AND is_active = 1
</select>

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tasteby.mapper.MemoMapper">
<resultMap id="memoResultMap" type="com.tasteby.domain.Memo">
<id property="id" column="id"/>
<result property="userId" column="user_id"/>
<result property="restaurantId" column="restaurant_id"/>
<result property="rating" column="rating"/>
<result property="memoText" column="memo_text" typeHandler="com.tasteby.config.ClobTypeHandler"/>
<result property="visitedAt" column="visited_at"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
<result property="restaurantName" column="restaurant_name"/>
</resultMap>
<select id="findByUserAndRestaurant" resultMap="memoResultMap">
SELECT id, user_id, restaurant_id, rating, memo_text,
visited_at, created_at, updated_at
FROM user_memos
WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
</select>
<insert id="insertMemo">
INSERT INTO user_memos (id, user_id, restaurant_id, rating, memo_text, visited_at)
VALUES (#{id}, #{userId}, #{restaurantId}, #{rating}, #{memoText},
<choose>
<when test="visitedAt != null">TO_DATE(#{visitedAt}, 'YYYY-MM-DD')</when>
<otherwise>NULL</otherwise>
</choose>)
</insert>
<update id="updateMemo">
UPDATE user_memos SET
rating = #{rating},
memo_text = #{memoText},
visited_at = <choose>
<when test="visitedAt != null">TO_DATE(#{visitedAt}, 'YYYY-MM-DD')</when>
<otherwise>NULL</otherwise>
</choose>,
updated_at = SYSTIMESTAMP
WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
</update>
<delete id="deleteMemo">
DELETE FROM user_memos WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
</delete>
<select id="findByUser" resultMap="memoResultMap">
SELECT m.id, m.user_id, m.restaurant_id, m.rating, m.memo_text,
m.visited_at, m.created_at, m.updated_at,
r.name AS restaurant_name
FROM user_memos m
LEFT JOIN restaurants r ON r.id = m.restaurant_id
WHERE m.user_id = #{userId}
ORDER BY m.updated_at DESC
</select>
</mapper>

View File

@@ -22,6 +22,9 @@
<result property="rating" column="rating"/>
<result property="ratingCount" column="rating_count"/>
<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>
<!-- ===== Queries ===== -->
@@ -29,7 +32,8 @@
<select id="findAll" resultMap="restaurantMap">
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
r.cuisine_type, r.price_range, r.google_place_id, r.tabling_url, r.catchtable_url,
r.business_status, r.rating, r.rating_count, r.updated_at
r.business_status, r.rating, r.rating_count, r.updated_at,
r.hidden, r.hidden_reason, r.verified_at
FROM restaurants r
<if test="channel != null and channel != ''">
JOIN video_restaurants vr_f ON vr_f.restaurant_id = r.id
@@ -39,6 +43,9 @@
<where>
r.latitude IS NOT NULL
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 != ''">
AND r.cuisine_type = #{cuisine}
</if>
@@ -62,14 +69,20 @@
</select>
<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,
vr.foods_mentioned, vr.evaluation, vr.guests,
vr.relevance, vr.relevance_reason,
c.channel_name, c.channel_id
FROM video_restaurants vr
JOIN videos v ON v.id = vr.video_id
JOIN channels c ON c.id = v.channel_id
WHERE vr.restaurant_id = #{restaurantId}
<if test="includeWeak == null or !includeWeak">
AND vr.relevance IN ('strong', 'unknown')
</if>
ORDER BY v.published_at DESC
</select>
@@ -239,6 +252,14 @@
ORDER BY r.name
</select>
<update id="resetTablingUrls">
UPDATE restaurants SET tabling_url = NULL WHERE tabling_url IS NOT NULL
</update>
<update id="resetCatchtableUrls">
UPDATE restaurants SET catchtable_url = NULL WHERE catchtable_url IS NOT NULL
</update>
<!-- ===== Remap operations ===== -->
<update id="updateCuisineType">
@@ -269,4 +290,84 @@
ORDER BY r.name
</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>

View File

@@ -79,7 +79,8 @@
</select>
<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
WHERE restaurant_id = #{restaurantId}
</select>

View File

@@ -30,12 +30,13 @@
JOIN video_restaurants vr ON vr.restaurant_id = r.id
JOIN videos v ON v.id = vr.video_id
WHERE r.latitude IS NOT NULL
AND (UPPER(r.name) LIKE UPPER(#{query})
OR UPPER(r.address) LIKE UPPER(#{query})
OR UPPER(r.region) LIKE UPPER(#{query})
OR UPPER(r.cuisine_type) LIKE UPPER(#{query})
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query})
OR UPPER(v.title) LIKE UPPER(#{query}))
<!-- #293 — ESCAPE 절로 사용자 입력의 %, _ 와일드카드 의도 우회 차단 -->
AND (UPPER(r.name) LIKE UPPER(#{query}) ESCAPE '\'
OR UPPER(r.address) LIKE UPPER(#{query}) ESCAPE '\'
OR UPPER(r.region) LIKE UPPER(#{query}) ESCAPE '\'
OR UPPER(r.cuisine_type) LIKE UPPER(#{query}) ESCAPE '\'
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query}) ESCAPE '\'
OR UPPER(v.title) LIKE UPPER(#{query}) ESCAPE '\')
FETCH FIRST #{limit} ROWS ONLY
</select>

View File

@@ -10,13 +10,13 @@
WHEN NOT MATCHED THEN INSERT (visit_date, visit_count) VALUES (src.d, 1)
</update>
<select id="getTodayVisits" resultType="int">
<select id="getTodayVisits" resultType="long">
SELECT NVL(visit_count, 0)
FROM site_visits
WHERE visit_date = TRUNC(SYSDATE)
</select>
<select id="getTotalVisits" resultType="int">
<select id="getTotalVisits" resultType="long">
SELECT NVL(SUM(visit_count), 0)
FROM site_visits
</select>

View File

@@ -12,6 +12,7 @@
<result property="createdAt" column="created_at"/>
<result property="favoriteCount" column="favorite_count"/>
<result property="reviewCount" column="review_count"/>
<result property="memoCount" column="memo_count"/>
</resultMap>
<select id="findByProviderAndProviderId" resultMap="userResultMap">
@@ -38,10 +39,12 @@
<select id="findAllWithCounts" resultMap="userResultMap">
SELECT u.id, u.email, u.nickname, u.avatar_url, u.provider, u.created_at,
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
FROM tasteby_users u
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_favorites GROUP BY user_id) fav ON fav.user_id = u.id
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_reviews GROUP BY user_id) rev ON rev.user_id = u.id
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_memos GROUP BY user_id) memo ON memo.user_id = u.id
ORDER BY u.created_at DESC
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
</select>

View File

@@ -62,15 +62,18 @@ if [[ "$TARGET" == "all" || "$TARGET" == "frontend" ]]; then
# Read build args from env or .env file
MAPS_KEY="${NEXT_PUBLIC_GOOGLE_MAPS_API_KEY:-}"
CLIENT_ID="${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}"
NAVER_MAP_ID="${NEXT_PUBLIC_NAVER_MAP_CLIENT_ID:-}"
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)}"
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
docker build --platform "$PLATFORM" \
--build-arg NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="$MAPS_KEY" \
--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:latest" \
frontend/

93
docs/README.md Normal file
View File

@@ -0,0 +1,93 @@
# tasteby 문서 아키텍처 (Documentation Map)
이 프로젝트의 문서는 **Diátaxis** 프레임워크 + **ADR** + **설계서(Design Spec)**
결합한 구조를 따른다. 모든 페르소나는 문서를 만들거나 참조할 때 이 지도를 기준으로 한다.
## 디렉토리 구조
```
docs/
README.md ← (이 파일) 문서 지도 · 인덱스
design/ ← 설계서: 구현 "전"에 작성하는 필수 산출물 (Design-First 게이트)
_TEMPLATE.md 기능 설계서 템플릿
_FN_TEMPLATE.md 함수별 설계서 템플릿
<issue-id>-<slug>/ 기능 1개(이슈 1개)당 폴더
README.md 기능 설계서 (전체 설계 + 함수 명세 표)
fn-<name>.md 복잡한 함수만 개별 함수 설계서
adr/ ← Architecture Decision Records: 가로지르는 결정 기록
_TEMPLATE.md
NNNN-<title>.md
reference/ ← 레퍼런스: 구현된 모듈/함수/설정 사양 (구현 "후" 동기화)
guides/ ← How-to / 사용 가이드 / 튜토리얼 (사용자·운영자 대상)
pipeline/ ← 개발 프로세스 문서 (큐 프로토콜·런북)
```
## Diátaxis 사분면 매핑
| 사분면 | 목적 | 여기서 위치 |
|--------|------|-------------|
| **Tutorials** (학습) | 처음 사용자가 따라하기 | `guides/` (getting-started) |
| **How-to** (문제해결) | 특정 작업 수행 | `guides/` |
| **Reference** (정보) | 정확한 사양 조회 | `reference/` |
| **Explanation** (이해) | 왜 이렇게 설계했나 | `design/`, `adr/` |
## 문서 종류와 책임
| 문서 | 작성 페르소나 | 시점 | 한 줄 |
|------|---------------|------|-------|
| 기능 설계서 `design/<id>/README.md` | **Architect** | 구현 **전** | 무엇을·어떻게 만들지의 청사진 |
| 함수 설계서 `design/<id>/fn-*.md` | **Architect** | 구현 **전** | 복잡 함수의 계약·알고리즘·테스트 |
| ADR `adr/NNNN-*.md` | **Architect** | 결정 시 | 되돌리기 어려운 선택과 근거 |
| 레퍼런스 `reference/*` | **Developer/Documenter** | 구현 **후** | 실제 코드 사양 |
| 가이드 `guides/*` | **Documenter** | 릴리스 시 | 사용/운영 방법 |
## 핵심 규칙 — Design-First (하드 게이트)
> **설계서 없이는 코드 없음.** 어떤 함수든 구현 전에 그 함수가 설계서로 덮여 있어야 한다
> (단순 함수: 기능 설계서의 함수 명세 표 / 복잡 함수: 개별 `fn-*.md`).
> Developer 는 설계서가 없으면 구현을 거부하고 Architect 단계로 반려한다.
> 자세한 기준은 `CLAUDE.md` §2 참조.
## 명명 · 추적성 규칙
- 설계서 폴더: `design/<issue-id>-<kebab-slug>/` (예: `design/45-trailing-stop/`).
- 함수 설계서: `fn-<function_name>.md` (예: `fn-calc_trailing_stop.md`).
- ADR: 4자리 일련번호 `adr/0001-<title>.md`, 번호 재사용 금지.
- 모든 설계서·ADR 상단에 **추적성 헤더**(Redmine 이슈, 관련 ADR, 구현 파일, 테스트)를 둔다.
- 코드 ↔ 설계서 양방향 링크: 설계서는 구현 파일 경로를, 코드 주석/문서는 설계서 경로를 가리킨다.
## 문서 수명주기
`Draft`(작성) → `Approved`(QA/Reviewer 통과 후) → `Superseded`(대체 시 상단 표기, 삭제 금지).
구현이 설계서와 달라지면 **코드가 아니라 설계서를 먼저 고치고** 다시 구현한다.
## 현존 설계서 인덱스 (2026-06-15 현행화)
### 백엔드 (12)
| Issue | 기능 | 설계서 |
|-------|------|--------|
| #266 | 인증/로그인 | [`design/266-backend-auth/README.md`](design/266-backend-auth/README.md) |
| #267 | 사용자 관리 | [`design/267-backend-user/README.md`](design/267-backend-user/README.md) |
| #268 | 식당 CRUD | [`design/268-backend-restaurant/README.md`](design/268-backend-restaurant/README.md) |
| #269 | 영상 관리 + SSE | [`design/269-backend-video/README.md`](design/269-backend-video/README.md) |
| #270 | 영상→식당 추출 파이프라인 | [`design/270-backend-extract-pipeline/README.md`](design/270-backend-extract-pipeline/README.md) |
| #271 | 검색/벡터 추천 | [`design/271-backend-search/README.md`](design/271-backend-search/README.md) |
| #272 | 리뷰/메모 | [`design/272-backend-review-memo/README.md`](design/272-backend-review-memo/README.md) |
| #273 | 채널 관리 | [`design/273-backend-channel/README.md`](design/273-backend-channel/README.md) |
| #274 | 통계/대시보드 | [`design/274-backend-stats/README.md`](design/274-backend-stats/README.md) |
| #275 | 데몬/스케줄러 | [`design/275-backend-daemon/README.md`](design/275-backend-daemon/README.md) |
| #276 | 캐시 관리 | [`design/276-backend-cache/README.md`](design/276-backend-cache/README.md) |
| #277 | Health/모니터링 | [`design/277-backend-health/README.md`](design/277-backend-health/README.md) |
### 프론트 (6)
| Issue | 기능 | 설계서 |
|-------|------|--------|
| #278 | 지도 뷰 | [`design/278-frontend-map/README.md`](design/278-frontend-map/README.md) |
| #279 | 식당 상세 시트 | [`design/279-frontend-restaurant-detail/README.md`](design/279-frontend-restaurant-detail/README.md) |
| #280 | 필터 시스템 | [`design/280-frontend-filter/README.md`](design/280-frontend-filter/README.md) |
| #281 | 리뷰/메모 UI | [`design/281-frontend-review-memo/README.md`](design/281-frontend-review-memo/README.md) |
| #282 | 어드민 페이지 | [`design/282-frontend-admin/README.md`](design/282-frontend-admin/README.md) |
| #283 | 로그인 메뉴 | [`design/283-frontend-login/README.md`](design/283-frontend-login/README.md) |
후속 개선 이슈는 Redmine 백로그(#289~#305)에서 추적.
```

24
docs/adr/_TEMPLATE.md Normal file
View File

@@ -0,0 +1,24 @@
<!-- ADR 템플릿. 복사해서 adr/NNNN-<kebab-title>.md (4자리 일련번호). -->
# ADR-NNNN: <제목>
> **상태**: Proposed <!-- Proposed | Accepted | Superseded by ADR-XXXX -->
> **날짜**: <YYYY-MM-DD> · **결정자**: [AI] Architect · **관련 이슈**: #<id>
## 맥락 (Context)
무엇이 이 결정을 강제하는가. 배경·제약·요구.
## 결정 (Decision)
우리는 무엇을 하기로 했는가. (명확한 한 문단)
## 근거 (Rationale)
왜 이 선택인가. 핵심 트레이드오프.
## 결과 (Consequences)
- **긍정**: ...
- **부정 / 비용**: ...
- **후속 작업**: ...
## 검토한 대안 (Alternatives Considered)
- **<대안 A>** — 기각 사유: ...
- **<대안 B>** — 기각 사유: ...

262
docs/deployment-guide.md Normal file
View File

@@ -0,0 +1,262 @@
# Tasteby 배포 가이드
## 환경 요약
| 항목 | Dev (개발) | Prod (운영) |
|------|-----------|-------------|
| URL | dev.tasteby.net | www.tasteby.net |
| 호스트 | 로컬 Mac mini | OKE (Oracle Kubernetes Engine) |
| 프로세스 관리 | PM2 | Kubernetes Deployment |
| 프론트엔드 실행 | `npm run dev` (Next.js dev server) | `node server.js` (standalone 빌드) |
| 백엔드 실행 | `./gradlew bootRun` | `java -jar app.jar` (bootJar 빌드) |
| Redis | 로컬 Redis 서버 | K8s Pod (redis:7-alpine) |
| TLS | Nginx(192.168.0.147) + Certbot | cert-manager + Let's Encrypt |
| 리버스 프록시 | Nginx (192.168.0.147 → 192.168.0.208) | Nginx Ingress Controller (K8s) |
| 도메인 DNS | dev.tasteby.net → Mac mini IP | www.tasteby.net → OCI NLB 217.142.131.194 |
---
## 1. Dev 환경 (dev.tasteby.net)
### 구조
```
브라우저 → dev.tasteby.net (HTTPS)
Nginx (192.168.0.147) — Certbot Let's Encrypt TLS
├── /api/* → proxy_pass http://192.168.0.208:8000 (tasteby-api)
└── /* → proxy_pass http://192.168.0.208:3001 (tasteby-web)
Mac mini (192.168.0.208) — PM2 프로세스 매니저
├── tasteby-api → ./gradlew bootRun (:8000)
└── tasteby-web → npm run dev (:3001)
```
- **192.168.0.147**: Nginx 리버스 프록시 서버 (TLS 종료, Certbot 자동 갱신)
- **192.168.0.208**: Mac mini (실제 앱 서버, PM2 관리)
### PM2 프로세스 구성 (ecosystem.config.js)
```javascript
module.exports = {
apps: [
{
name: "tasteby-api",
cwd: "/Users/joungmin/workspaces/tasteby/backend-java",
script: "./start.sh", // gradlew bootRun 실행
interpreter: "/bin/bash",
},
{
name: "tasteby-web",
cwd: "/Users/joungmin/workspaces/tasteby/frontend",
script: "npm",
args: "run dev", // ⚠️ 절대 standalone으로 바꾸지 말 것!
},
],
};
```
### 백엔드 start.sh
```bash
#!/bin/bash
export JAVA_HOME="/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home"
export PATH="/opt/homebrew/opt/openjdk@21/bin:$PATH"
set -a
source /Users/joungmin/workspaces/tasteby/backend/.env # 환경변수 로드
set +a
exec ./gradlew bootRun
```
### 코드 수정 후 반영 방법
```bash
# 프론트엔드: npm run dev라서 코드 수정 시 자동 Hot Reload (재시작 불필요)
# 백엔드: 코드 수정 후 재시작 필요
pm2 restart tasteby-api
# 전체 재시작
pm2 restart tasteby-api tasteby-web
# PM2 상태 확인
pm2 status
# 로그 확인
pm2 logs tasteby-api --lines 50
pm2 logs tasteby-web --lines 50
```
### 주의사항
- `tasteby-web`은 반드시 `npm run dev`로 실행 (dev server)
- standalone 모드(`node .next/standalone/server.js`)로 바꾸면 static/public 파일을 못 찾아서 404 발생
- standalone은 prod(Docker/K8s) 전용
- dev 포트: 프론트 3001, 백엔드 8000 (3000은 Gitea가 사용 중)
- 환경변수는 `backend/.env`에서 로드
---
## 2. Prod 환경 (www.tasteby.net)
### 구조
```
브라우저 → www.tasteby.net (HTTPS)
OCI Network Load Balancer (217.142.131.194)
↓ 80→NodePort:32530, 443→NodePort:31437
Nginx Ingress Controller (K8s)
├── /api/* → backend Service (:8000)
└── /* → frontend Service (:3001)
```
### 클러스터 정보
- **OKE 클러스터**: tasteby-cluster-prod
- **노드**: ARM64 × 2 (2 CPU / 8GB)
- **네임스페이스**: tasteby
- **K8s context**: `context-c6ap7ecrdeq`
### Pod 구성
| Pod | Image | Port | 리소스 |
|-----|-------|------|--------|
| backend | `icn.ocir.io/idyhsdamac8c/tasteby/backend:TAG` | 8000 | 500m~1 CPU, 768Mi~1536Mi |
| frontend | `icn.ocir.io/idyhsdamac8c/tasteby/frontend:TAG` | 3001 | 200m~500m CPU, 256Mi~512Mi |
| redis | `docker.io/library/redis:7-alpine` | 6379 | 100m~200m CPU, 128Mi~256Mi |
### 배포 명령어 (deploy.sh)
```bash
# 전체 배포 (백엔드 + 프론트엔드)
./deploy.sh "배포 메시지"
# 백엔드만 배포
./deploy.sh --backend-only "백엔드 수정 사항"
# 프론트엔드만 배포
./deploy.sh --frontend-only "프론트 수정 사항"
# 드라이런 (실제 배포 없이 확인)
./deploy.sh --dry-run "테스트"
```
### deploy.sh 동작 순서
1. **버전 계산**: 최신 git tag에서 patch +1 (v0.1.9 → v0.1.10)
2. **Docker 빌드**: Colima로 `linux/arm64` 이미지 빌드 (로컬 Mac에서)
- 백엔드: `backend-java/Dockerfile` → multi-stage (JDK build → JRE runtime)
- 프론트: `frontend/Dockerfile` → multi-stage (node build → standalone runtime)
3. **OCIR Push**: `icn.ocir.io/idyhsdamac8c/tasteby/{backend,frontend}:TAG` + `:latest`
4. **K8s 배포**: `kubectl set image``kubectl rollout status` (롤링 업데이트)
5. **Git tag**: `vX.Y.Z` 태그 생성 후 origin push
### Docker 빌드 상세
**백엔드 Dockerfile** (multi-stage):
```dockerfile
# Build: eclipse-temurin:21-jdk에서 gradlew bootJar
# Runtime: eclipse-temurin:21-jre에서 java -jar app.jar
# JVM 옵션: -XX:MaxRAMPercentage=75.0 -XX:+UseG1GC
```
**프론트엔드 Dockerfile** (multi-stage):
```dockerfile
# Build: node:22-alpine에서 npm ci + npm run build
# Runtime: node:22-alpine에서 standalone 출력물 복사 + node server.js
# ⚠️ standalone 모드는 Docker(prod) 전용. .next/static과 public을 직접 복사해야 함
```
### Ingress 설정
```yaml
# 주요 annotation
cert-manager.io/cluster-issuer: letsencrypt-prod # 자동 TLS 인증서
nginx.ingress.kubernetes.io/ssl-redirect: "true" # HTTP → HTTPS 리다이렉트
nginx.ingress.kubernetes.io/from-to-www-redirect: "true" # tasteby.net → www 리다이렉트
# 라우팅
www.tasteby.net/api/* → backend:8000
www.tasteby.net/* → frontend:3001
```
### TLS 인증서 (cert-manager)
- ClusterIssuer: `letsencrypt-prod`
- HTTP-01 challenge 방식 (포트 80 필수)
- Secret: `tasteby-tls`
- 인증서 상태 확인: `kubectl get certificate -n tasteby`
### 운영 확인 명령어
```bash
# Pod 상태
kubectl get pods -n tasteby
# 로그 확인
kubectl logs -f deployment/backend -n tasteby
kubectl logs -f deployment/frontend -n tasteby
# 인증서 상태
kubectl get certificate -n tasteby
# Ingress 상태
kubectl get ingress -n tasteby
# 롤백 (이전 이미지로)
kubectl rollout undo deployment/backend -n tasteby
kubectl rollout undo deployment/frontend -n tasteby
```
---
## 3. OCI 네트워크 구성
### VCN 서브넷
| 서브넷 | CIDR | 용도 |
|--------|------|------|
| oke-k8sApiEndpoint-subnet | 10.0.0.0/28 | K8s API 서버 |
| oke-nodesubnet | 10.0.10.0/24 | 워커 노드 |
| oke-svclbsubnet | 10.0.20.0/24 | NLB (로드밸런서) |
### 보안 리스트 (Security List)
**LB 서브넷** (oke-svclbsubnet):
- Ingress: `0.0.0.0/0` → TCP 80, 443
- Egress: `10.0.10.0/24` → all (노드 서브넷 전체 허용)
**노드 서브넷** (oke-nodesubnet):
- Ingress: `10.0.10.0/24` → all (노드 간 통신)
- Ingress: `10.0.0.0/28` → TCP all (API 서버)
- Ingress: `0.0.0.0/0` → TCP 22 (SSH)
- Ingress: `10.0.20.0/24` → TCP 30000-32767 (LB → NodePort)
- Ingress: `0.0.0.0/0` → TCP 30000-32767 (NLB preserve-source 대응)
> ⚠️ NLB `is-preserve-source: true` 설정으로 클라이언트 원본 IP가 보존됨.
> 따라서 노드 서브넷에 `0.0.0.0/0` → NodePort 인바운드가 반드시 필요.
---
## 4. OCIR (컨테이너 레지스트리) 인증
```bash
# 로그인
docker login icn.ocir.io -u idyhsdamac8c/oracleidentitycloudservice/<email> -p <auth-token>
```
- Registry: `icn.ocir.io/idyhsdamac8c/tasteby/`
- K8s imagePullSecret: `ocir-secret` (namespace: tasteby)
---
## 5. 자주 하는 실수 / 주의사항
| 실수 | 원인 | 해결 |
|------|------|------|
| dev에서 static 404 | PM2를 standalone 모드로 바꿈 | `npm run dev`로 원복 |
| prod HTTPS 타임아웃 | NLB 보안 리스트 NodePort 불일치 | egress를 노드 서브넷 all 허용 |
| 인증서 발급 실패 | 포트 80 방화벽 차단 | LB 서브넷 ingress 80 + 노드 서브넷 NodePort 허용 |
| OKE에서 이미지 pull 실패 | CRI-O short name 불가 | `docker.io/library/` 풀네임 사용 |
| NLB 헬스체크 실패 | preserve-source + 노드 보안 리스트 | 0.0.0.0/0 → NodePort 인바운드 추가 |

View File

@@ -0,0 +1,173 @@
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: 백엔드 - 인증/로그인 (#266)
> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #266 · 관련 ADR: 없음
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/AuthService.java`, `backend-java/src/main/java/com/tasteby/controller/AuthController.java` · 테스트: TBD (현재 없음)
## 1. 목적 (Why)
Tasteby 사용자가 Google 계정으로 1탭 로그인하여 즐겨찾기/리뷰/메모 등 개인화 기능을 사용할 수 있도록 한다. 자체 가입/비밀번호 운영 부담을 제거하고 검증된 ID 토큰 기반으로 안전한 세션 토큰(JWT)을 발급한다.
## 2. 범위 (Scope)
- **포함**
- Google OAuth ID Token 검증 후 사용자 조회/생성(Upsert) → 자체 JWT 발급 (`POST /api/auth/google`).
- 현재 로그인 사용자 정보 반환 (`GET /api/auth/me`).
- Google 검증 실패/사용자 미존재 시 표준 HTTP 에러 매핑.
- **제외 (out of scope)**
- 자체 ID/PW 회원가입·비밀번호 재설정.
- Apple/Kakao/Naver 등 추가 소셜 로그인.
- 리프레시 토큰, 토큰 회수(blacklist).
- 로그아웃 처리(클라이언트 토큰 삭제로 처리).
- 권한 부여(role) 변경 — 사용자 관리(#267) 책임.
## 3. 인수조건 (Acceptance Criteria)
- [ ] 유효한 Google ID Token으로 `POST /api/auth/google` 호출 시 `access_token`(JWT)과 `user` 객체를 반환한다.
- [ ] 신규 Google 계정 첫 로그인 시 `tasteby_users` 행이 생성되고, 재로그인 시 `last_login_at`이 갱신된다.
- [ ] Google ID Token이 위조/만료/오디언스 불일치인 경우 HTTP 401을 반환한다.
- [ ] 발급된 JWT를 `Authorization: Bearer ...``GET /api/auth/me` 호출 시 본인 정보를 반환한다.
- [ ] JWT의 sub가 존재하지 않는 사용자 ID인 경우 `GET /api/auth/me`는 HTTP 404를 반환한다.
## 4. 컨텍스트 & 제약
- **의존성**
- `google-api-client``GoogleIdTokenVerifier`(`NetHttpTransport` + `GsonFactory`).
- `UserService``UserMapper`(MyBatis) → Oracle 23ai (`tasteby_users`).
- 자체 `JwtTokenProvider` (HMAC 서명 가정), `AuthUtil`(SecurityContext에서 userId 추출).
- **제약**
- Google Client ID는 `app.google.client-id` 프로퍼티로 단일 audience로 고정. 모바일/웹 다중 클라이언트 ID는 현 시점 미지원.
- JWT 만료/서명 정책은 `JwtTokenProvider`에서 관리(본 설계서 범위 외).
- CORS는 `WebConfig`에서 `POST`/`GET` 허용 필요(이미 적용).
- 모든 외부 호출은 동기 HTTP, 실패 시 401로 합쳐서 반환.
- **가정**
- Google ID Token의 `sub`는 영구 고유 식별자이며, 동일 사용자가 이메일을 바꾸어도 `(provider='google', providerId=sub)`로 식별 가능.
- 사용자 객체의 `nickname`/`avatarUrl`은 최초 생성 시 Google payload 값을 그대로 저장(이후 사용자 편집은 본 기능 범위 외).
## 5. 아키텍처 개요
- **모듈/파일**
- `controller/AuthController.java` — HTTP 진입점 (thin).
- `service/AuthService.java` — Google 검증 + JWT 발급 오케스트레이션.
- `service/UserService.java#findOrCreate/findById` — DB 조회/upsert.
- `security/JwtTokenProvider`, `security/AuthUtil` — 토큰 생성 / SecurityContext 추출.
- **데이터 흐름**
```
[Client]
│ POST /api/auth/google { id_token }
AuthController.loginGoogle
AuthService.loginGoogle
├─ GoogleIdTokenVerifier.verify(idToken) ── (외부 I/O: Google 공개키 검증)
├─ UserService.findOrCreate(provider, sub, email, name, picture)
│ └─ UserMapper.findByProviderAndProviderId / insert / updateLastLogin
└─ JwtTokenProvider.createToken(userMap)
{ access_token, user }
[Client] GET /api/auth/me (Authorization: Bearer <jwt>)
AuthController.me → AuthUtil.getUserId() → AuthService.getCurrentUser
▼ UserService.findById → UserMapper.findById
UserInfo
```
- **I/O ↔ 순수 로직 경계**
- I/O: Google 토큰 검증, DB 조회/저장.
- 순수: payload → `UserInfo` 매핑, `Map<String,Object>` 클레임 빌드.
## 6. 데이터 모델
- **입력**
- `POST /api/auth/google` body: `{ "id_token": string(JWT, Google issued) }`.
- `GET /api/auth/me`: 헤더 `Authorization: Bearer <accessToken>`.
- **출력**
- `loginGoogle`: `{ access_token: string, user: UserInfo }`.
- `me`: `UserInfo`.
- **`UserInfo`(domain/UserInfo.java)**
- `id: String(32 hex)`, `email: String`, `nickname: String`, `avatarUrl: String`, `admin: boolean (@JsonProperty("is_admin"))`, `provider: String`, `providerId: String`, `createdAt: String`, `favoriteCount/reviewCount/memoCount: int`.
- **저장(`tasteby_users`)**
- PK: `id` (`IdGenerator.newId()`, 32-char uppercase hex), 유니크 가정: `(provider, provider_id)`.
- `is_admin NUMBER`(0/1), `last_login_at TIMESTAMP`.
- **경계 검증**
- `id_token` 비어있거나 null → Google verifier가 `null` 리턴 → 401.
- JWT 클레임 내 `email`/`nickname`이 null이면 빈 문자열로 정규화.
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------|------|------|-----------|-------|
| `AuthService(UserService, JwtTokenProvider, String)` | 의존성 주입 및 GoogleIdTokenVerifier 초기화 | `AuthService(UserService userService, JwtTokenProvider jwtProvider, @Value("${app.google.client-id}") String googleClientId)` | DI 빈, client-id | 인스턴스 | 프로퍼티 누락 시 빈 생성 실패 | 단순 |
| `AuthService.loginGoogle` | Google ID Token 검증 → 사용자 upsert → JWT 발급 | `Map<String,Object> loginGoogle(String idTokenString)` | Google id_token 문자열 | `{ access_token, user }` | 검증 실패/예외 → 401 `ResponseStatusException` | **복잡** (외부 I/O + DB upsert + 토큰 발급) |
| `AuthService.getCurrentUser` | JWT sub로 사용자 조회 | `UserInfo getCurrentUser(String userId)` | userId | `UserInfo` | 미존재 → 404 | 단순 |
| `AuthController(AuthService)` | DI | 생성자 | DI 빈 | 인스턴스 | 없음 | 단순 |
| `AuthController.loginGoogle` | `/api/auth/google` 엔드포인트 | `Map<String,Object> loginGoogle(@RequestBody Map<String,String> body)` | body.id_token | `{ access_token, user }` | AuthService 예외 위임 | 단순 |
| `AuthController.me` | `/api/auth/me` 엔드포인트 | `UserInfo me()` | (헤더에서 userId 자동 추출) | `UserInfo` | 인증 실패 → 401, 미존재 → 404 | 단순 |
> 복잡 표시 함수(`loginGoogle`)는 흐름이 8장에 상세 기술되어 있어 별도 `fn-loginGoogle.md`는 생략 가능.
## 8. 흐름 / 알고리즘
**시나리오 A — Google 로그인**
1. 클라이언트가 Google Identity Services로 ID Token을 발급받아 `POST /api/auth/google {id_token}` 호출.
2. `AuthService``GoogleIdTokenVerifier.verify`로 서명/만료/aud 검증. null이면 401.
3. payload에서 `sub`, `email`, `name`, `picture` 추출.
4. `UserService.findOrCreate("google", sub, email, name, picture)` 호출.
- 기존 유저: `updateLastLogin` 후 최신 사용자 반환.
- 신규 유저: `IdGenerator.newId()`로 PK 발급 → insert → 재조회 반환.
5. `UserInfo`의 핵심 필드를 `Map`으로 패키징하여 `JwtTokenProvider.createToken` 호출.
6. `{ access_token, user }` 응답.
**시나리오 B — 현재 사용자 조회 (`/api/auth/me`)**
1. Spring Security 필터가 Bearer 토큰을 검증해 `SecurityContext`에 principal(userId) 설정.
2. `AuthUtil.getUserId()`로 sub 추출.
3. `AuthService.getCurrentUser``UserService.findById``UserMapper.findById`.
4. 없으면 404, 있으면 `UserInfo` 반환.
## 9. 엣지케이스 & 에러 처리
- **id_token이 null/공백**: Verifier가 null 또는 예외 발생 → 401 "Invalid Google token".
- **Google 공개키 조회 실패(네트워크/타임아웃)**: catch-all로 401에 메시지 포함. 재시도/백오프 없음(클라이언트가 재시도).
- **audience 불일치**: Verifier가 null 반환 → 401.
- **신규 사용자 insert 중 충돌**: 트랜잭션(`@Transactional`)으로 묶여 있으며, (provider, provider_id) 유니크 위반 시 예외 발생 → 상위에서 500 변환(전역 예외 처리에 의존). 동시 첫 로그인은 드물어 별도 재시도 없음.
- **`findById` race**: insert 직후 즉시 재조회 — 동일 트랜잭션 가시성 가정.
- **JWT 클레임 내 email/nickname null**: 빈 문자열로 정규화 후 토큰에 포함.
- **`/api/auth/me`에서 sub가 존재하지 않는 ID(사용자 삭제 등)**: 404.
- **안전한 기본값**: 어떤 실패든 401/404로 매핑, 500은 예외적.
## 10. 테스트 계획
- **현 상태**: 자동화 테스트 없음 (TBD).
- **추가 권장 단위 테스트** (Mockito 기반)
- `AuthService.loginGoogle`
- 유효 토큰 → `UserService.findOrCreate` 호출 + `JwtTokenProvider.createToken` 결과 포함.
- Verifier null 반환 → 401.
- Verifier 예외 → 401.
- email/nickname null payload → 토큰 클레임에 빈 문자열.
- `AuthService.getCurrentUser`
- 존재 → `UserInfo` 반환.
- 미존재 → 404.
- **통합 테스트** (`@SpringBootTest` + MockMvc)
- `POST /api/auth/google` happy path (Google verifier 모킹).
- `GET /api/auth/me` 인증 헤더 유효/무효.
- **모킹 전략**: `GoogleIdTokenVerifier``@MockBean`으로 교체. JWT는 실제 `JwtTokenProvider` 사용해 round-trip 검증.
## 11. 리스크 & 대안 검토
- **선택**: Google 단일 IdP + 자체 단기 JWT.
- 장점: 구현 단순, 비밀번호 미관리, 즉시 사용 가능.
- 단점: 리프레시 토큰 없음 → 만료 시 재로그인 필요.
- **대안 1**: Spring Security OAuth2 Client + 세션 쿠키.
- 트레이드오프: 백엔드 세션 저장소 추가, SPA-친화 낮음. 현재 거부.
- **대안 2**: 리프레시 토큰 + 회수 리스트(Redis).
- 트레이드오프: 복잡도 ↑. 향후 필요 시 도입.
- **되돌리기 어려운 결정**: `(provider, provider_id)` 식별 스키마. → 변경 시 ADR 필요.
- **보안 리스크**
- JWT 시크릿 유출 시 위조 가능. 시크릿은 `k8s/secrets.yaml`로 관리.
- audience 단일 — 모바일/웹 client_id 분리 시 verifier 다중 audience 지원 필요.
## 12. 미해결 질문 (Open Questions)
- 리프레시 토큰을 도입할 것인가, 단기 만료 + 재로그인을 유지할 것인가?
- 사용자 닉네임/프로필 사진을 매 로그인마다 Google 값으로 덮어쓸지(현 코드는 첫 생성만 반영) 여부.
- 어드민 권한 부여 정책(최초 가입자 admin, 환경변수 화이트리스트 등)을 어디서 결정할지.
- 멀티 클라이언트(웹/iOS/Android)별 Google Client ID 분리 시점.
- 토큰 만료/서명 알고리즘(HS256 → RS256 전환) 시점.

View File

@@ -0,0 +1,205 @@
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: 백엔드 - 사용자 관리 (#267)
> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #267 · 관련 ADR: 없음
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/UserService.java`, `backend-java/src/main/java/com/tasteby/controller/AdminUserController.java`, `backend-java/src/main/java/com/tasteby/mapper/UserMapper.java`, `backend-java/src/main/resources/mybatis/mapper/UserMapper.xml` · 테스트: TBD (현재 없음)
## 1. 목적 (Why)
관리자(`is_admin=1`)가 가입 사용자 목록과 각 사용자의 활동(즐겨찾기·리뷰·메모)을 조회하고, 관리자 권한을 부여/회수할 수 있도록 한다. 또한 인증(#266) 흐름에서 호출되는 사용자 upsert/조회의 단일 책임 지점을 제공한다.
## 2. 범위 (Scope)
- **포함**
- 사용자 upsert/조회 도메인 서비스 (`UserService.findOrCreate`, `findById`).
- 관리자 전용 사용자 목록 조회(페이징, 활동 카운트 포함) — `GET /api/admin/users`.
- 사용자별 즐겨찾기/리뷰/메모 조회 — `GET /api/admin/users/{userId}/{favorites|reviews|memos}`.
- 관리자 권한 토글 — `PATCH /api/admin/users/{userId}/admin`.
- **제외 (out of scope)**
- 회원 탈퇴/익명화, 개인정보 수정 API.
- 일반 사용자가 자기 프로필을 수정하는 API.
- 사용자 자체 검색(이름/이메일).
- 즐겨찾기/리뷰/메모의 CRUD (각 도메인 서비스 책임).
- Google 토큰 검증 (#266 책임).
## 3. 인수조건 (Acceptance Criteria)
- [x] 관리자 토큰으로 `GET /api/admin/users?limit=&offset=` 호출 시 `{ users:[…], total:n }` 구조와 각 사용자의 `favoriteCount/reviewCount/memoCount`가 포함된다.
- [x] `/api/admin/users/**` 모든 엔드포인트(GET 4종 + PATCH)는 진입 시 `AuthUtil.requireAdmin()`을 호출하여 비관리자 토큰에 대해 403을 반환한다. (보안 핫픽스 2026-06-15)
- [ ] `findOrCreate``(provider, providerId)`로 기존 사용자가 있으면 `last_login_at`만 갱신하고, 없으면 신규 PK로 INSERT한다.
- [ ] `PATCH /api/admin/users/{userId}/admin {admin: true|false}` 호출 시 자기 자신의 권한을 변경하면 400을 반환한다.
- [ ] 존재하지 않는 사용자 ID로 `updateAdmin` 호출 시 404를 반환한다.
- [ ] 관리자 권한 변경 후 응답에 `{ success:true, user_id, is_admin }`이 포함되고, 서버 로그에 변경자/대상/값이 기록된다.
## 4. 컨텍스트 & 제약
- **의존성**
- Oracle 23ai 테이블: `tasteby_users`, `user_favorites`, `user_reviews`, `user_memos`.
- MyBatis (`UserMapper.xml`) — resultMap으로 UPPERCASE 컬럼 → 도메인 매핑.
- `ReviewService`, `MemoService` — 사용자별 활동 조회 위임.
- `AuthUtil.requireAdmin()` — JWT의 admin 클레임 검사.
- **제약**
- 모든 `/api/admin/**` 엔드포인트는 관리자만 호출 가능.
- 페이징 기본 `limit=50, offset=0`. 상한 강제 없음(향후 캡 고려).
- `is_admin`은 Oracle NUMBER(0/1) → Java boolean (`@JsonProperty("is_admin")`).
- 트랜잭션: upsert 및 권한 변경은 `@Transactional`.
- **가정**
- `UserInfo.id``IdGenerator.newId()` 32-char hex로 사전 발급.
- `findAllWithCounts`는 LEFT JOIN으로 활동 미존재 시 0 반환.
## 5. 아키텍처 개요
- **모듈/파일**
- `controller/AdminUserController.java` — 관리자 전용 엔드포인트.
- `service/UserService.java` — upsert·조회·권한 변경.
- `mapper/UserMapper.java` + `resources/mybatis/mapper/UserMapper.xml` — SQL 매핑.
- `domain/UserInfo.java` — Lombok DTO.
- **데이터 흐름**
```
[Admin Client]
│ GET /api/admin/users?limit&offset (Bearer JWT, is_admin=true)
AdminUserController.listUsers
├─ UserService.findAllWithCounts(limit, offset)
│ └─ UserMapper.xml: SELECT u.* + LEFT JOIN (fav/rev/memo COUNT)
└─ UserService.countAll
{ users:[UserInfo], total }
[Admin Client]
│ PATCH /api/admin/users/{id}/admin {admin}
AdminUserController.updateAdmin
├─ AuthUtil.requireAdmin() (자기 자신 변경 거부)
└─ UserService.updateAdmin → UserMapper.updateAdmin
{ success, user_id, is_admin }
[AuthService (#266)]
▼ UserService.findOrCreate
├─ findByProviderAndProviderId → updateLastLogin
└─ insert + findById
```
- **I/O ↔ 순수 로직 경계**
- I/O: MyBatis Mapper(DB).
- 순수: 권한 변경 시 자기 자신 검사, boolean→int 변환.
## 6. 데이터 모델
- **`UserInfo`** (`domain/UserInfo.java`)
- `id: String`, `email: String`, `nickname: String`, `avatarUrl: String`, `admin: boolean(@JsonProperty("is_admin"))`, `provider: String`, `providerId: String`, `createdAt: String`, `favoriteCount/reviewCount/memoCount: int`.
- **저장(`tasteby_users`)**
- `id PK(32)`, `provider VARCHAR`, `provider_id VARCHAR`, `email`, `nickname`, `avatar_url`, `is_admin NUMBER(1)`, `created_at TIMESTAMP`, `last_login_at TIMESTAMP`.
- 유니크(가정): `(provider, provider_id)`.
- **입력**
- `GET /api/admin/users`: query `limit:int(=50)`, `offset:int(=0)`.
- `PATCH /api/admin/users/{userId}/admin`: body `{ admin: boolean }`.
- **출력**
- 목록: `{ users: UserInfo[], total: int }`.
- 사용자 즐겨찾기/리뷰/메모: `Restaurant[]`, `Review[]`, `Memo[]`.
- 권한 변경: `{ success:true, user_id, is_admin }`.
- **경계 검증**
- `limit/offset` 정수, 음수 가드 없음 — 호출자 신뢰. Oracle FETCH NEXT가 0/음수 시 결과 0건.
- body.admin이 null → `Boolean.TRUE.equals(null)` = false 처리.
## 7. 함수 명세 (Function Specs)
### UserService (public)
| 함수 | 책임(1줄) | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------|------|------|-----------|-------|
| `UserService(UserMapper)` | DI | 생성자 | DI 빈 | 인스턴스 | 없음 | 단순 |
| `findOrCreate` | provider+providerId로 사용자 upsert + 마지막 로그인 갱신 | `UserInfo findOrCreate(String provider, String providerId, String email, String nickname, String avatarUrl)` | 5개 문자열 | `UserInfo` | DB 예외 → 500 | **복잡** (분기 + 트랜잭션) |
| `findById` | PK로 단건 조회 | `UserInfo findById(String userId)` | userId | `UserInfo` or null | DB 예외 → 500 | 단순 |
| `findAllWithCounts` | 활동 카운트 포함 페이징 목록 | `List<UserInfo> findAllWithCounts(int limit, int offset)` | 페이징 | 사용자 리스트 | DB 예외 → 500 | 단순 |
| `countAll` | 전체 사용자 수 | `int countAll()` | 없음 | int | DB 예외 → 500 | 단순 |
| `updateAdmin` | 관리자 플래그 변경 | `void updateAdmin(String userId, boolean admin)` | userId, boolean | void | 미존재 → 404 | 단순 |
### UserMapper (public, MyBatis)
| 함수 | 책임 | 시그니처 | 출력 |
|------|------|----------|------|
| `findByProviderAndProviderId` | provider+providerId 조회 | `UserInfo findByProviderAndProviderId(String provider, String providerId)` | `UserInfo`/null |
| `updateLastLogin` | last_login_at = SYSTIMESTAMP | `void updateLastLogin(String id)` | void |
| `insert` | 신규 사용자 INSERT | `void insert(UserInfo user)` | void |
| `findById` | PK 조회 | `UserInfo findById(String id)` | `UserInfo`/null |
| `findAllWithCounts` | 활동 COUNT 조인 페이징 | `List<UserInfo> findAllWithCounts(int limit, int offset)` | 목록 |
| `countAll` | 전체 카운트 | `int countAll()` | int |
| `updateAdmin` | `is_admin` UPDATE | `int updateAdmin(String id, int admin)` | 영향 행 수 |
### AdminUserController (public)
| 함수 | 책임 | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? |
|------|------|----------|------|------|-----------|-------|
| `AdminUserController(...)` | DI | 생성자 | DI 빈 | 인스턴스 | 없음 | 단순 |
| `listUsers` | `GET /api/admin/users` | `Map<String,Object> listUsers(int limit=50, int offset=0)` | 페이징 | `{users,total}` | 인증 실패 → 401/403 | 단순 |
| `userFavorites` | `GET …/{userId}/favorites` | `List<Restaurant> userFavorites(String userId)` | userId | 즐겨찾기 식당 | 인증/위임 | 단순 |
| `userReviews` | `GET …/{userId}/reviews` | `List<Review> userReviews(String userId)` | userId | 리뷰 100건 | 인증/위임 | 단순 |
| `userMemos` | `GET …/{userId}/memos` | `List<Memo> userMemos(String userId)` | userId | 메모 | 인증/위임 | 단순 |
| `updateAdmin` | `PATCH …/{userId}/admin` | `Map<String,Object> updateAdmin(String userId, Map<String,Boolean> body)` | userId, `{admin}` | `{success,user_id,is_admin}` | 자기 자신 → 400, 미존재 → 404 | **복잡** (정책 분기) |
## 8. 흐름 / 알고리즘
**A. Upsert (`findOrCreate`)**
1. `findByProviderAndProviderId(provider, providerId)` 호출.
2. 존재 → `updateLastLogin(id)``findById(id)` 반환.
3. 미존재 → `IdGenerator.newId()`로 PK 발급 → `UserInfo` 빌드 → `insert``findById(newId)` 반환.
4. 전체 트랜잭션(`@Transactional`)으로 묶여 부분 실패 시 롤백.
**B. 관리자 목록 (`listUsers`)**
1. 컨트롤러에서 limit/offset 기본값 적용.
2. `findAllWithCounts``LEFT JOIN ( user_favorites|user_reviews|user_memos GROUP BY user_id )` + `ORDER BY created_at DESC OFFSET ? FETCH NEXT ?`.
3. `countAll`로 전체 수 합산하여 `{ users, total }` 반환.
**C. 권한 변경 (`updateAdmin`)**
1. `AuthUtil.requireAdmin()` — JWT의 admin 클레임 미보유 시 403.
2. `userId == currentUser.subject` → 400 ("자기 자신의 관리자 권한은 변경할 수 없습니다").
3. body.admin → boolean (null=false).
4. `UserService.updateAdmin` → Mapper에서 `UPDATE … WHERE id = ?`.
5. 영향 행 0 → 404, 1 → 감사 로그 `[ADMIN] User {} set admin={} for user {}` 출력 후 성공 응답.
## 9. 엣지케이스 & 에러 처리
- **자기 자신 권한 변경**: 명시적으로 400으로 차단(마지막 관리자 사고 방지).
- **존재하지 않는 사용자 권한 변경**: Mapper 영향 행 0 → 404.
- **음수 limit/offset**: Oracle은 OFFSET 0 ROWS FETCH NEXT 음수 시 결과 0건. 클라이언트 신뢰.
- **활동 카운트 0**: `NVL(…, 0)`으로 0으로 노출.
- **`findById` race(인증 직후 삭제)**: null 반환 → 호출자(AuthService)에서 404.
- **email/nickname null** (Google에서 일부 제공 안 함): 컬럼 NULL 허용 가정. UI에서 빈 값 처리.
- **권한 변경 동시성**: 트랜잭션 + 단일 UPDATE이므로 마지막 쓰기 승리(last-write-wins). 감사 로그로 추적.
- **안전한 기본값**: 권한 변경 실패 시 변경 없음.
## 10. 테스트 계획
- **현 상태**: 자동화 테스트 없음 (TBD).
- **단위 테스트** (Mockito)
- `UserService.findOrCreate`
- 기존 사용자 → `updateLastLogin` + `findById` 호출 검증.
- 신규 사용자 → `insert` + `findById(newId)` 호출 검증.
- `UserService.updateAdmin`
- 영향 행 1 → 정상.
- 영향 행 0 → 404 예외.
- **컨트롤러 통합 테스트** (MockMvc + `@MockBean`)
- `GET /api/admin/users` 정상/페이징 파라미터.
- `PATCH /admin` 자기 자신 → 400, 미존재 → 404, 정상 → 200 + 응답 구조.
- 비-관리자 토큰 → 403.
- **Mapper 통합** (`@MybatisTest` + Testcontainers Oracle)
- `findByProviderAndProviderId` 일치/미일치.
- `findAllWithCounts` 활동 카운트 정확성.
- **모킹 전략**: `AuthUtil` 정적 메서드는 `Mockito.mockStatic`으로 stub.
## 11. 리스크 & 대안 검토
- **선택**: 관리자 전용 엔드포인트 분리 + 일반 사용자용 프로필 API 없음.
- 장점: 권한 경계 단순.
- 단점: 일반 사용자가 자기 프로필 수정 시 별도 API 신설 필요.
- **대안 1**: `is_admin` 외 RBAC(role 테이블).
- 트레이드오프: 복잡도 ↑. 현 규모에서는 과설계.
- **대안 2**: 활동 카운트를 캐시/뷰로 분리.
- 트레이드오프: 사용자 수 증가 시 JOIN 비용 ↑ — 그때 도입.
- **되돌리기 어려운 결정**: `is_admin` boolean → 다중 역할로 확장 시 마이그레이션 필요.
- **운영 리스크**
- 마지막 관리자 권한 회수: 자기 자신 차단으로 부분 보호. 다른 관리자가 회수하면 무관리자 상태 가능 → 향후 "최소 1명 admin 유지" 가드 고려.
- 사용자 페이징에 상한 없음 → 큰 limit으로 메모리 압박 가능.
## 12. 미해결 질문 (Open Questions)
- 일반 사용자가 자기 프로필(닉네임/아바타)을 수정하는 API를 어디에 둘 것인지(`UserController` 신설 vs `/api/auth/me PATCH`).
- 사용자 검색(이메일/닉네임 부분 일치)을 관리자 화면에 추가할 것인지.
- "최소 1명 admin 유지" 가드를 도입할지, 운영 정책으로만 둘지.
- 활동 카운트 페이지 캐싱 TTL을 도입할지(현재 Redis 캐시 미적용).
- 회원 탈퇴/익명화 정책과 개인정보 보관기간.

View File

@@ -0,0 +1,277 @@
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: 백엔드 - 식당 CRUD (#268)
> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #268 · 관련 ADR: 없음
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/RestaurantService.java`, `backend-java/src/main/java/com/tasteby/controller/RestaurantController.java` · 테스트: TBD (현재 없음)
## 1. 목적 (Why)
사용자에게 식당 목록/상세를 빠르게 제공하고, 관리자에게는 식당 정보를 안전하게 수정/삭제하며 외부 예약 채널(테이블링·캐치테이블) URL을 자동/수동으로 연결할 수 있도록 한다. 추출기 파이프라인이 호출하는 식당 upsert 및 영상-식당 링크 생성의 단일 책임 지점을 제공한다.
## 2. 범위 (Scope)
- **포함**
- 목록/상세 조회 (`GET /api/restaurants`, `GET /api/restaurants/{id}`) — Redis 캐시.
- 식당 수정/삭제 (관리자 전용) — 이름·주소 변경 시 재지오코딩.
- 식당별 영상 연결 조회 (`GET /api/restaurants/{id}/videos`).
- 테이블링/캐치테이블 단건 검색, 미연결 목록, SSE 벌크 자동 연결, URL 저장, 초기화.
- 추출기 파이프라인이 호출하는 upsert(`upsert`), 영상-식당 링크(`linkVideoRestaurant`), 분류 보정(`updateCuisineType`, `updateFoodsMentioned`).
- **제외 (out of scope)**
- 식당 신규 등록 전용 엔드포인트(POST) — 등록은 추출기 파이프라인(`upsert`) 경유.
- YouTube 자막/메타 추출, 지오코딩 자체 로직 (각 서비스 책임).
- 즐겨찾기·리뷰·메모 CRUD.
- 식당 검색(이름/지역/메뉴 키워드 검색 API) — 본 설계서 미포함.
- 벡터 임베딩 생성 (`VectorService` 책임).
## 3. 인수조건 (Acceptance Criteria)
- [ ] `GET /api/restaurants?limit=&offset=&cuisine=&region=&channel=` 결과는 캐시되며, `channels`/`foodsMentioned`가 채워진 `Restaurant` 리스트를 반환한다.
- [ ] `PUT /api/restaurants/{id}`에서 `name` 또는 `address`가 변경된 경우 Geocoding을 재호출하여 좌표·`google_place_id`·rating 등을 갱신한다.
- [ ] `DELETE /api/restaurants/{id}``tasteby_restaurants`와 함께 벡터/리뷰/즐겨찾기/영상 링크를 모두 삭제한다.
- [ ] 관리자 미인증 사용자가 PUT/DELETE/관리자 엔드포인트 호출 시 403/401을 반환한다.
- [ ] `POST /api/restaurants/bulk-tabling`(SSE) 호출 시 미연결 식당에 대해 DuckDuckGo로 검색 → 유사도 ≥ 0.4면 URL 저장, 아니면 `NONE` 기록, 진행 상황을 이벤트로 스트리밍한다.
- [ ] `upsert``google_place_id` 또는 동일 `name`이 있으면 UPDATE, 없으면 신규 ID로 INSERT 한다.
## 4. 컨텍스트 & 제약
- **의존성**
- Oracle 23ai: `tasteby_restaurants`, `video_restaurant_links`, `tasteby_restaurant_vectors`, `user_favorites`, `user_reviews`.
- MyBatis `RestaurantMapper`.
- `CacheService` (Redis) — 목록/상세/영상 캐시 + 변경 시 `flush()`.
- `GeocodingService` — 이름/주소 → 좌표/place_id/주소/평점.
- 외부 HTTP: `html.duckduckgo.com` (테이블링/캐치테이블 검색).
- `AuthUtil.requireAdmin()`.
- 가상 스레드 풀(`Executors.newVirtualThreadPerTaskExecutor()`) — SSE 비동기.
- **제약**
- 목록 limit 최대 500으로 캡.
- 벌크 SSE 타임아웃 600초, 각 검색 사이 2~5초 랜덤 딜레이.
- 캐시 key 패턴: `restaurants:…`, `restaurant:{id}`, `restaurant_videos:{id}`.
- `name` 200바이트, `address` 500바이트 UTF-8 트렁케이션.
- 외부 검색은 비공식 스크래핑(DDG HTML) — Rate limit/봇 차단 가능성.
- 권한: 조회는 익명 허용, 수정/삭제/외부 검색/벌크는 관리자.
- **가정**
- `RestaurantMapper`의 동적 SQL이 `cuisine/region/channel` 필터를 지원.
- `evaluation` 필드는 `JsonUtil.normalizeEvaluation`으로 300자 제한 + JSON 래핑.
## 5. 아키텍처 개요
- **모듈/파일**
- `controller/RestaurantController.java` — HTTP/SSE.
- `service/RestaurantService.java` — 도메인 로직 + enrichment.
- `mapper/RestaurantMapper`(MyBatis) — SQL.
- `service/GeocodingService`, `service/CacheService` — 외부 협력.
- `util/JsonUtil`, `util/IdGenerator` — 공통.
- **데이터 흐름**
```
[Client]
│ GET /api/restaurants?…
RestaurantController.list
├─ CacheService.getRaw(key) ── hit ─▶ deserialize
└─ miss ─▶ RestaurantService.findAll
├─ RestaurantMapper.findAll(limit,offset,cuisine,region,channel)
└─ enrichRestaurants
├─ findChannelsByRestaurantIds
└─ findFoodsByRestaurantIds (JsonUtil.parseStringList)
CacheService.set(key, result)
[Admin] PUT /api/restaurants/{id}
▼ AuthUtil.requireAdmin
▼ RestaurantService.findById (404)
▼ if name/address changed → GeocodingService.geocodeRestaurant → body 보강
▼ RestaurantService.update → Mapper.updateFields
▼ CacheService.flush → findById → 응답
[Admin] POST /api/restaurants/bulk-tabling (SSE)
▼ findWithoutTabling → for each:
searchTabling(name) ─▶ DDG HTML (외부 I/O)
→ isNameSimilar? YES: update tabling_url
NO : update 'NONE'
→ emit event, sleep 2~5s
▼ cache.flush, complete
[Extractor pipeline]
▼ RestaurantService.upsert(map)
├─ findIdByPlaceId / findIdByName
└─ insertRestaurant or updateRestaurant
▼ linkVideoRestaurant(videoId, restaurantId, foods, eval, guests)
```
- **I/O ↔ 순수 로직 경계**
- I/O: MyBatis, Redis, GeocodingService, DDG HTTP.
- 순수: `enrichRestaurants` 매핑, `truncateBytes`, `isNameSimilar`, `normalize`, `extractDdgUrl`.
## 6. 데이터 모델
- **`Restaurant`** (`domain/Restaurant.java`)
- `id, name, address, region, latitude(Double), longitude(Double), cuisineType, priceRange, phone, website, googlePlaceId, tablingUrl, catchtableUrl, businessStatus, rating(Double), ratingCount(Integer), updatedAt(Date)`.
- Transient: `channels: List<String>`, `foodsMentioned: List<String>`.
- **저장 테이블**
- `tasteby_restaurants` (PK `id`, 후보 키 `google_place_id` / 동명 폴백).
- `video_restaurant_links` (`id PK`, `video_id`, `restaurant_id`, `foods_mentioned CLOB`, `evaluation CLOB`, `guests CLOB`).
- 부속: `tasteby_restaurant_vectors`, `user_favorites`, `user_reviews` (DELETE 캐스케이드 수동).
- **입력**
- 목록 query: `limit(=100,≤500)`, `offset(=0)`, `cuisine?`, `region?`, `channel?`.
- 수정 body: 자유 형 `Map<String,Object>` (name/address/cuisine_type/price_range/website/phone/tabling_url 등).
- 권한 변경 / URL 저장: `{ tabling_url | catchtable_url: string }`.
- **출력**
- 목록/상세: `Restaurant`(Jackson SNAKE_CASE 직렬화).
- 영상 링크: `List<Map>``foods_mentioned/evaluation/guests`는 파싱 후 객체.
- SSE 이벤트 타입: `start | processing | done | notfound | error | complete`.
- **경계 검증**
- `name`/`address` UTF-8 200/500바이트로 잘라 저장.
- `evaluation``JsonUtil.normalizeEvaluation`(평문 → JSON, 300자 제한).
- `latitude/longitude/rating/rating_count`는 Number → primitive 변환 시 null 안전.
## 7. 함수 명세 (Function Specs)
### RestaurantService (public)
| 함수 | 책임 | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? |
|------|------|----------|------|------|-----------|-------|
| `RestaurantService(RestaurantMapper)` | DI | 생성자 | DI 빈 | 인스턴스 | 없음 | 단순 |
| `findAll` | 필터+페이징 목록 + 채널/메뉴 enrich | `List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel)` | 페이징/필터 | 식당 리스트 | DB 예외 → 500 | **복잡** (조인 enrich) |
| `findWithoutTabling` | tabling_url 미연결 식당 | `List<Restaurant> findWithoutTabling()` | 없음 | 리스트 | DB 예외 | 단순 |
| `findWithoutCatchtable` | catchtable_url 미연결 식당 | `List<Restaurant> findWithoutCatchtable()` | 없음 | 리스트 | DB 예외 | 단순 |
| `resetTablingUrls` | 모든 tabling_url 초기화 | `void resetTablingUrls()` | 없음 | void | DB 예외 | 단순 |
| `resetCatchtableUrls` | 모든 catchtable_url 초기화 | `void resetCatchtableUrls()` | 없음 | void | DB 예외 | 단순 |
| `findById` | 단건 조회 + enrich | `Restaurant findById(String id)` | id | `Restaurant`/null | DB 예외 | 단순 |
| `findVideoLinks` | 영상-식당 링크 + JSON 파싱 | `List<Map<String,Object>> findVideoLinks(String restaurantId)` | restaurantId | foods/eval/guests 파싱된 리스트 | DB 예외 | 단순 |
| `update` | 임의 필드 부분 업데이트 | `void update(String id, Map<String,Object> fields)` | id, fields | void | DB 예외 | 단순 |
| `delete` | 식당 및 종속 데이터 일괄 삭제 | `void delete(String id)` | id | void | DB 예외 → 롤백 | **복잡** (5개 테이블 캐스케이드) |
| `upsert` | place_id/name으로 기존 매칭, 없으면 INSERT | `String upsert(Map<String,Object> data)` | 추출 결과 | restaurantId | DB 예외 | **복잡** (분기 + 트렁케이션) |
| `linkVideoRestaurant` | 영상-식당 N:M 링크 + JSON 직렬화 | `void linkVideoRestaurant(String videoId, String restaurantId, List<String> foods, String evaluation, List<String> guests)` | 5개 | void | DB 예외 | 단순 |
| `updateCuisineType` | 분류 보정 | `void updateCuisineType(String id, String cuisineType)` | id, type | void | DB 예외 | 단순 |
| `updateFoodsMentioned` | 메뉴 목록 보정 | `void updateFoodsMentioned(String id, String foods)` | id, foods | void | DB 예외 | 단순 |
| `findForRemapCuisine` | 재분류 대상 조회 | `List<Map<String,Object>> findForRemapCuisine()` | 없음 | 행 리스트 | DB 예외 | 단순 |
| `findForRemapFoods` | 재분류 대상 조회 | `List<Map<String,Object>> findForRemapFoods()` | 없음 | 행 리스트 | DB 예외 | 단순 |
> private: `enrichRestaurants`, `truncateBytes` — 표 외 처리.
### RestaurantController (public)
| 함수 | 책임/엔드포인트 | 시그니처 | 권한 | 출력 | 에러 | 복잡? |
|------|------|----------|------|------|------|-------|
| 생성자 | DI | `RestaurantController(...)` | — | 인스턴스 | 없음 | 단순 |
| `list` | `GET /api/restaurants` (캐시) | `List<Restaurant> list(int limit=100, int offset=0, String cuisine?, String region?, String channel?)` | 익명 | 목록 | 캐시 역직렬화 실패 시 silent fallback | **복잡** (캐시 미스/히트 분기) |
| `get` | `GET /{id}` (캐시) | `Restaurant get(String id)` | 익명 | `Restaurant` | 미존재 → 404 | 단순 |
| `update` | `PUT /{id}` (조건부 재지오코딩) | `Map update(String id, Map body)` | admin | `{ok, restaurant}` | 404 / 권한 | **복잡** (지오코딩 분기 + cache flush) |
| `delete` | `DELETE /{id}` | `Map delete(String id)` | admin | `{ok}` | 404 / 권한 | 단순 |
| `tablingSearch` | `GET /{id}/tabling-search` | `List<Map> tablingSearch(String id)` | admin | DDG 결과 | 404 / 502 | **복잡** (외부 I/O) |
| `tablingPending` | `GET /tabling-pending` | `Map tablingPending()` | admin | `{count, restaurants[]}` | 권한 | 단순 |
| `bulkTabling` | `POST /bulk-tabling` (SSE) | `SseEmitter bulkTabling()` | admin | SSE 스트림 | per-item error 이벤트, 최종 complete | **복잡** (장기 비동기 + 외부 I/O + 상태 전이) |
| `setTablingUrl` | `PUT /{id}/tabling-url` | `Map setTablingUrl(String id, Map body)` | admin | `{ok}` | 404 / 권한 | 단순 |
| `resetTabling` | `DELETE /reset-tabling` | `Map resetTabling()` | admin | `{ok}` | 권한 | 단순 |
| `resetCatchtable` | `DELETE /reset-catchtable` | `Map resetCatchtable()` | admin | `{ok}` | 권한 | 단순 |
| `catchtableSearch` | `GET /{id}/catchtable-search` | `List<Map> catchtableSearch(String id)` | admin | DDG 결과 | 404 / 502 | **복잡** |
| `catchtablePending` | `GET /catchtable-pending` | `Map catchtablePending()` | admin | `{count,…}` | 권한 | 단순 |
| `bulkCatchtable` | `POST /bulk-catchtable` (SSE) | `SseEmitter bulkCatchtable()` | admin | SSE 스트림 | 동상 | **복잡** |
| `setCatchtableUrl` | `PUT /{id}/catchtable-url` | `Map setCatchtableUrl(String id, Map body)` | admin | `{ok}` | 404 / 권한 | 단순 |
| `videos` | `GET /{id}/videos` (캐시) | `List<Map> videos(String id)` | 익명 | 영상 링크 | 404 | 단순 |
> private 유틸: `searchDuckDuckGo`, `extractDdgUrl`, `searchTabling`, `searchCatchtable`, `isNameSimilar`, `normalize`, `emit` — 표 외. 외부 I/O 동반.
## 8. 흐름 / 알고리즘
**A. 목록 조회 (캐시 적용)**
1. limit > 500이면 500으로 캡.
2. `CacheService.makeKey("restaurants", l=…, o=…, c=…, r=…, ch=…)` 생성.
3. Redis HIT → 역직렬화 반환(역직렬화 실패는 무시 후 미스 처리).
4. MISS → `findAll` 호출 → `enrichRestaurants`로 채널/메뉴 채움 → 캐시 set.
**B. 수정(`PUT /{id}`) — 조건부 재지오코딩**
1. 관리자 확인 → 404 가드.
2. body의 `name`/`address`가 기존과 다르면 `geocodeRestaurant` 호출.
3. 결과 좌표/`google_place_id`/`rating`/`phone`/`business_status`/`formatted_address` 보강.
4. `formatted_address`에서 `parseRegionFromAddress``region` 재계산.
5. `Mapper.updateFields(id, body)` 부분 업데이트 → `cache.flush()` → 재조회 응답.
**C. 삭제(`DELETE /{id}`)**
- 순서: `deleteVectors → deleteReviews → deleteFavorites → deleteVideoRestaurants → deleteRestaurant` (외래 무결성 보호). `@Transactional`로 원자성.
**D. 벌크 테이블링/캐치테이블 (SSE)**
1. 관리자 확인. `SseEmitter(timeout=600s)` 생성.
2. 가상 스레드에서: `findWithoutTabling()` → for-each.
3. `emit("processing")``searchTabling(name)` → DDG HTML 검색 → 결과 5개 이내 추출.
4. 결과 있고 `isNameSimilar(name, top.title) == true``update(tabling_url)` + `emit("done")`.
5. 결과 없거나 유사도 불충분 → `update("NONE")` + `emit("notfound")`.
6. 예외 → `emit("error")`.
7. 각 검색 후 `Thread.sleep(2000~5000ms)` 랜덤 딜레이.
8. 전체 완료 → `cache.flush()``emit("complete")``emitter.complete()`.
**E. Upsert (`upsert`)**
1. `google_place_id` 우선 매칭 → `findIdByPlaceId`.
2. 없으면 `findIdByName`.
3. name/address UTF-8 트렁케이션, Number 필드 안전 변환.
4. 기존 ID 있으면 UPDATE, 없으면 새 `IdGenerator.newId()`로 INSERT. 반환: restaurantId.
**F. 영상-식당 링크 (`linkVideoRestaurant`)**
- `foods/guests` → JSON 직렬화, `evaluation``JsonUtil.normalizeEvaluation` 후 INSERT.
**G. 이름 유사도 (`isNameSimilar`)**
- normalize: 공백·구두점·괄호 제거, lowercase.
- 포함 관계 또는 문자 집합 Jaccard-like 비율 ≥ 0.4.
## 9. 엣지케이스 & 에러 처리
- **limit > 500**: 500으로 강제 캡.
- **캐시 역직렬화 실패**: 무시하고 DB로 폴백(catch `Exception ignored`).
- **`findById` null**: 일반 GET/PUT/DELETE에서 404.
- **수정 시 지오코딩 실패(null 반환)**: body 그대로 update — 좌표 미갱신 허용.
- **삭제 캐스케이드 부분 실패**: 트랜잭션 롤백.
- **upsert place_id 동일·다른 이름**: place_id로 매칭 → UPDATE.
- **DDG 검색 결과 0건/이름 불일치**: `tabling_url='NONE'`(검색 다시 시도 방지 sentinel).
- **DDG HTTP 실패/예외**: 단건은 502, 벌크는 per-item error 이벤트.
- **벌크 SSE 클라이언트 단절**: `emitter.send` 예외 catch → 디버그 로그, 작업 진행.
- **레이트리밋/봇 차단**: 2~5초 랜덤 딜레이 + User-Agent 위장. 차단 발생 시 대량 'NONE' 기록 위험 → 운영자 모니터링 필요.
- **이름 트렁케이션 손실**: UTF-8 200/500 바이트로 잘라 멀티바이트 안전.
- **evaluation 평문 입력**: `normalizeEvaluation`이 JSON 래핑 + 300자 제한.
- **안전한 기본값**: 외부 I/O 실패 시 DB 변경 없음(검색 단건의 경우). 벌크는 진행하면서 실패 이벤트 emit.
## 10. 테스트 계획
- **현 상태**: 자동화 테스트 없음 (TBD).
- **단위 테스트** (Mockito)
- `RestaurantService.upsert`
- place_id 매칭 → UPDATE 경로.
- name 매칭 → UPDATE.
- 미매칭 → INSERT + 새 ID.
- name 250바이트 → 200바이트 트렁케이션.
- `RestaurantService.delete`
- 5개 mapper delete 순서 호출 검증.
- `RestaurantService.enrichRestaurants` (private이지만 `findAll` 경유)
- 채널/메뉴 매핑 정확성, null 처리.
- `RestaurantController.isNameSimilar` (정적·private)
- 포함/제외/유사도 경계 0.4.
- **통합 테스트** (`@SpringBootTest` + MockMvc)
- `GET /api/restaurants` 캐시 HIT/MISS 동작 (Redis embedded 또는 Testcontainers).
- `PUT /{id}` 이름/주소 변경 시 GeocodingService 호출 검증 (`@MockBean`).
- `DELETE /{id}` 트랜잭션 롤백 (예외 주입).
- 관리자 권한 가드 401/403.
- **SSE 테스트**
- `bulkTabling` 0건/일부 매칭/불일치/에러 시나리오 — `@MockBean`으로 DDG 결과 stub.
- 진행 이벤트 순서 검증.
- **모킹 전략**
- `httpClient`(DDG)는 인스턴스 추출 가능하도록 리팩토링 후 `@MockBean` (현재 static — 테스트 가능성 낮음, 향후 개선).
- `CacheService`/`GeocodingService``@MockBean`.
## 11. 리스크 & 대안 검토
- **DDG HTML 스크래핑**
- 장점: API 키 불필요, 즉시 사용.
- 위험: HTML 구조 변경/봇 차단 시 대량 `NONE` 마킹 → 실 데이터 손상 가능.
- 대안: 테이블링/캐치테이블 비공식 API 직접 호출, 또는 검색 API(Bing/Naver) 도입 — 비용·약관 검토 필요.
- **캐시 무효화 전략**: 변경 시 전체 `flush()`.
- 장점: 단순.
- 단점: 무관한 키도 일괄 무효화. 트래픽 큰 시점에 부담.
- 대안: 키 prefix 기반 부분 삭제(`scan + del`).
- **`PUT /{id}` body가 `Map<String,Object>`**: 타입 안전성 낮음, 임의 컬럼 업데이트 허용.
- 대안: DTO + 화이트리스트. 보안/감사 향상.
- **벌크 SSE 600초 타임아웃**: 식당 수가 많을 경우 부족. 청크 분할/재개 기능 미지원.
- **이름 유사도 임계값 0.4**: 한글 짧은 이름에서 오탐 가능. 향후 ngram·자모 분해 기반 알고리즘 검토.
- **upsert의 동명 매칭**: place_id 없는 데이터에서 동명이체 식당이 합쳐질 수 있음 — 추출기 단계에서 place_id 보장 필요.
- **트랜잭션 경계**: `delete`는 트랜잭션, `upsert`/`update`는 메서드 단위 트랜잭션 없음(MyBatis 단일 SQL이므로 영향 작음).
## 12. 미해결 질문 (Open Questions)
- `region` 필터 값 컨벤션(`"한국|서울|강남구"`)을 enum/마스터 테이블로 표준화할지.
- DDG 스크래핑을 정식 검색 API로 대체할 시점/예산.
- `tabling_url = 'NONE'` sentinel을 별도 컬럼/플래그로 분리할지(현재 URL 컬럼에 의미 오버로드).
- 관리자 수정 PUT을 화이트리스트 DTO로 강제할지.
- 벌크 SSE 작업을 큐(Redis Streams) + 워커로 분리해 재개 가능하게 만들지.
- 이름 유사도 알고리즘을 한국어 특화(자모, 초성)로 교체할지.
- 캐시 키 그룹별 부분 무효화 도입 여부.

View File

@@ -0,0 +1,214 @@
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: 백엔드 - 영상 관리 + SSE (#269)
> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #269 · 관련 ADR: 없음
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/VideoService.java`, `backend-java/src/main/java/com/tasteby/service/YouTubeService.java`, `backend-java/src/main/java/com/tasteby/controller/VideoController.java`, `backend-java/src/main/java/com/tasteby/controller/VideoSseController.java` · 테스트: TBD (현재 없음)
## 1. 목적 (Why)
유튜브 채널의 영상 메타데이터를 스캔·저장하고, 자막(transcript)을 확보하며, 식당 추출 파이프라인 진입까지의 영상 생명주기를 관리한다. 다건 처리 진행 상황은 SSE 로 실시간 스트리밍하여 운영자가 어드민에서 모니터링한다.
## 2. 범위 (Scope)
- **포함**:
- 영상 목록/상세 조회, 제목 수정, 상태(`pending|processing|done|skip|no_transcript|error`) 변경, 삭제.
- 영상-식당 링크 단건 삭제 + 고아 식당/벡터/리뷰/즐겨찾기 정리.
- YouTube Data API v3 기반 채널 스캔 (PlaylistItems 우선, Search API 폴백, Shorts 60초 이하 필터).
- 자막 확보: Playwright Headed 브라우저(쿠키 로드, 광고 스킵, 한국어 우선) → 실패 시 `youtube-transcript-api` 폴백.
- 운영자가 브라우저 확장 등으로 수집한 transcript 업로드.
- SSE 스트림: `bulk-transcript`, `bulk-extract`, `remap-cuisine`, `remap-foods`, `rebuild-vectors`.
- 단건 추출 트리거 (`POST /api/videos/{id}/extract`)와 수동 식당 추가 (`/restaurants/manual`).
- **제외 (out of scope)**:
- LLM 기반 식당 추출 본체와 Geocoding (→ #270).
- 검색/벡터 추천 질의 (→ #271).
- 채널 마스터 CRUD (→ #273).
- 프론트엔드 어드민 UI (→ #282).
## 3. 인수조건 (Acceptance Criteria)
- [ ] `GET /api/videos?status=pending` 호출 시 상태별 영상 목록을 반환하고, 상세 (`GET /api/videos/{id}`)에는 transcript 와 식당 링크 배열이 포함된다 (`evaluation``JsonUtil.normalizeEvaluation` 으로 정규화).
- [ ] `DELETE /api/videos/{id}` 가 단일 트랜잭션으로 벡터·리뷰·즐겨찾기·식당·링크·영상 순으로 정리해 고아 레코드를 남기지 않는다.
- [ ] 채널 스캔(`YouTubeService.scanChannel`)은 PlaylistItems API 로 전체 업로드를 페이징하며, `publishedAfter` 이후 영상만 가져오고 Shorts(60초 이하)를 제거한 뒤 `saveVideosBatch` 로 중복 없이 저장한다.
- [ ] `POST /api/videos/{id}/fetch-transcript` 가 브라우저 → API 순으로 자막을 시도하고, 성공 시 길이/소스(`browser`/`manual (ko)`/`generated (en)` 등)를 응답에 포함한다.
- [ ] `POST /api/videos/bulk-transcript` SSE 가 `start → processing → done|skip|error → api_pass → complete` 이벤트 시퀀스를 JSON 으로 송출하고, 30분 타임아웃 + 3~8초 랜덤 딜레이로 봇 탐지를 회피한다.
- [ ] 모든 admin 엔드포인트는 `AuthUtil.requireAdmin()` 가드를 통과해야 하며 캐시 변경 후 `CacheService.flush()` 가 호출된다.
## 4. 컨텍스트 & 제약
- **의존성**:
- DB: Oracle 23ai (videos, video_restaurants, restaurants, restaurant_vectors, reviews, favorites).
- 외부 API: YouTube Data API v3 (`app.google.youtube-api-key`).
- 자막 라이브러리: `io.github.thoroldvix.api.YoutubeTranscriptApi` + Playwright Chromium.
- 내부 서비스: `PipelineService`, `ExtractorService`, `RestaurantService`, `GeocodingService`, `OciGenAiService`, `CacheService` (`#270`/`#271`/`#276`).
- **제약**:
- YouTube API quota (PlaylistItems 1 unit/페이지, Search 100 unit/페이지 → 우선 PlaylistItems 사용).
- Playwright Headed 모드는 Mac mini Dev 환경 가정 (`pm2 tasteby-api`); 헤드리스 환경(OKE prod) 미지원이므로 SSE bulk-transcript 는 dev 에서만 사용한다.
- SSE Emitter 타임아웃: transcript 30 분, extract/remap 10 분.
- LLM 호출 비용 → bulk 작업 시 3~8초 랜덤 딜레이로 호출량 제어.
- transcript CLOB 저장, `MyBatis ClobTypeHandler` 로 매핑.
- **가정**:
- 영상 ID(`videos.id`)는 32-char UUID(`IdGenerator.newId()`), `video_id` 는 YouTube 11자 ID.
- admin 권한 사용자만 모든 mutation/SSE 를 호출한다.
- 운영자는 `cookies.txt` 를 백엔드 작업 디렉토리에 두어 Playwright 로그인을 우회한다.
## 5. 아키텍처 개요
- 모듈/파일:
- `controller/VideoController.java` — 동기 CRUD/단건 작업.
- `controller/VideoSseController.java` — SSE 다건 작업 (Virtual Thread executor).
- `service/VideoService.java` — Mapper 위임 + transcript/evaluation 정규화.
- `service/YouTubeService.java` — YouTube API + Playwright + transcript-api.
- `mapper/VideoMapper.java` (+ `mybatis/mapper/VideoMapper.xml`) — DB 접근.
- 도메인: `VideoSummary`, `VideoDetail`, `VideoRestaurantLink`.
- I/O ↔ 순수 로직 경계:
- **I/O**: YouTube REST 호출, Playwright 브라우저, DB INSERT/UPDATE, SSE emit.
- **순수 로직**: `parseDuration`, `filterShorts` 필터 조건, `evaluation` JSON 정규화 (`JsonUtil.normalizeEvaluation`), 페이지네이션 중단 조건(`publishedAfter` 이전 발견 시 break).
```
[Admin UI] --HTTP--> VideoController ---> VideoService ---> VideoMapper ---> Oracle
\---> YouTubeService --(WebClient)--> YouTube Data API v3
--(Playwright)--> youtube.com
--(transcript-api)--> timedtext
[Admin UI] --SSE--> VideoSseController --(VirtualThread)--> {
YouTubeService.createBrowserSession + getTranscriptWithPage
PipelineService.processExtract (#270)
OciGenAiService.chat (cuisine/foods remap)
RestaurantService.update* (#268)
} --emit JSON event--> Admin UI
[Pipeline scan] cron/daemon --> YouTubeService.scanAllChannels
--> ChannelService + VideoService.saveVideosBatch
```
## 6. 데이터 모델
- **입력**:
- `POST /{id}/fetch-transcript` 쿼리: `mode ∈ {auto, manual, generated}`.
- `POST /{id}/upload-transcript` body: `{ text: string(≥1), source?: string }`.
- `POST /{id}/extract` body(옵션): `{ prompt?: string }`.
- `POST /{videoId}/restaurants/manual` body: `{ name(필수), address?, region?, cuisine_type?, price_range?, foods_mentioned?: string|string[], guests?: string|string[], evaluation?: string }`.
- `PUT /{videoId}/restaurants/{restaurantId}` body: 위 필드 + 이름/주소 변경 시 재-geocode.
- SSE body: `{ ids?: string[] }` (없으면 전체 pending).
- **출력**:
- `VideoSummary` 목록 (id, videoId, title, url, status, publishedAt, channelName, hasTranscript, hasLlm, restaurantCount, matchedCount).
- `VideoDetail` = summary + `transcript`(CLOB) + `restaurants: VideoRestaurantLink[]`.
- `VideoRestaurantLink`: restaurantId, name, address, cuisineType, priceRange, region, foodsMentioned(@JsonRawValue JSON), evaluation(@JsonRawValue JSON), guests(@JsonRawValue JSON), googlePlaceId, lat/lng, `hasLocation` 파생.
- SSE 이벤트 공통 키: `type ∈ {start, processing, done, skip, error, api_pass, wait, batch_done, retry, complete}`.
- **저장**:
- `videos(id PK, channel_id FK, video_id, title, url, published_at, status, transcript_text CLOB, llm_response CLOB)`.
- `video_restaurants(video_id, restaurant_id, foods_mentioned IS JSON, evaluation IS JSON, guests IS JSON)``evaluation` 컬럼은 DB CHECK `IS JSON` 제약.
- **검증 규칙**:
- `title`, `text` blank 금지 → 400.
- `evaluation` 문자열은 JSON 리터럴(`{`/`"` 시작)이 아니면 `JsonUtil.toJson` 으로 문자열 래핑.
- transcript 8000자 초과는 ExtractorService 가 머리/꼬리만 남기고 절단(`#270`).
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `VideoController.list` | 상태별 영상 목록 | `list(String status)` | status (옵션) | `List<VideoSummary>` | 없음 (빈 배열) | 단순 |
| `VideoController.detail` | 영상 상세 + 식당 링크 | `detail(String id)` | id | `VideoDetail` | 404 NotFound | 단순 |
| `VideoController.updateTitle` | 제목 수정 | `updateTitle(id, body)` | id, title | `{ok}` | 400 blank, 403 admin | 단순 |
| `VideoController.skip` | 영상 skip 처리 | `skip(id)` | id | `{ok}` | 403 | 단순 |
| `VideoController.delete` | 영상 + 종속 cascade 삭제 | `delete(id)` | id | `{ok}` | 403, TX rollback | **복잡** |
| `VideoController.deleteVideoRestaurant` | 영상-식당 링크 + 고아 정리 | `deleteVideoRestaurant(videoId, restaurantId)` | id 2종 | `{ok}` | 403 | **복잡** |
| `VideoController.fetchTranscript` | 자막 자동 수집(browser→api) | `fetchTranscript(id, mode)` | id, mode | `{ok,length,source}` | 400 자막없음, 404 | **복잡** |
| `VideoController.uploadTranscript` | 외부 수집 자막 저장 | `uploadTranscript(id, body)` | id, text | `{ok,length,source}` | 400, 404 | 단순 |
| `VideoController.getExtractPrompt` | LLM 추출 프롬프트 조회 | `getExtractPrompt()` | — | `{prompt}` | 없음 | 단순 |
| `VideoController.extract` | 단건 LLM 추출 실행 | `extract(id, body)` | id, prompt? | `{ok,count}` | 400 transcript 없음 | **복잡** |
| `VideoController.bulkExtractPending` | 추출 대상 영상 목록 | `bulkExtractPending()` | — | `{count,videos}` | 없음 | 단순 |
| `VideoController.bulkTranscriptPending` | 자막 미보유 영상 목록 | `bulkTranscriptPending()` | — | `{count,videos}` | 없음 | 단순 |
| `VideoController.addManualRestaurant` | 수동 식당 추가 + geocode + 링크 | `addManualRestaurant(videoId, body)` | videoId, body | `{ok, restaurant_id}` | 400 name 없음 | **복잡** |
| `VideoController.updateVideoRestaurant` | 링크/식당 필드 수정 + 재-geocode | `updateVideoRestaurant(videoId, restaurantId, body)` | id 2종, fields | `{ok}` | 403 | **복잡** |
| `VideoSseController.bulkTranscript` | SSE 다건 자막 (browser→api 2pass) | `bulkTranscript(body)` | ids? | `SseEmitter` | emit error/skip | **복잡** |
| `VideoSseController.bulkExtract` | SSE 다건 LLM 추출 | `bulkExtract(body)` | ids? | `SseEmitter` | emit error | **복잡** |
| `VideoSseController.remapCuisine` | cuisine_type 재분류 (배치+retry) | `remapCuisine()` | — | `SseEmitter` | LLM 실패 시 retry | **복잡** |
| `VideoSseController.remapFoods` | foods_mentioned 재생성 | `remapFoods()` | — | `SseEmitter` | LLM 실패 시 retry | **복잡** |
| `VideoSseController.rebuildVectors` | 벡터 재생성 자리 | `rebuildVectors()` | — | `SseEmitter` | TODO 비어있음 | 단순 |
| `VideoSseController.process` | 동기 N건 파이프라인 | `process(limit)` | limit (≤?) | `{count}` | 없음 | 단순 |
| `VideoService.findDetail` | 상세 + 링크 + evaluation 정규화 | `findDetail(id)` | id | `VideoDetail|null` | null 가능 | 단순 |
| `VideoService.delete` | 트랜잭션 6단계 정리 | `delete(id)` | id | void | TX rollback | **복잡** |
| `VideoService.deleteVideoRestaurant` | 링크 + 고아 cleanup | `deleteVideoRestaurant(...)` | id 2종 | void | TX rollback | **복잡** |
| `VideoService.saveVideosBatch` | 중복 제외 신규 영상 일괄 insert | `saveVideosBatch(channelId, videos)` | dbId, list | 저장 건수 | 부분 실패 시 catch 없음 | 단순 |
| `VideoService.findPendingVideos` | pending 상태 영상 N개 | `findPendingVideos(limit)` | limit | `List<Map>` | 없음 | 단순 |
| `VideoService.findVideosForBulkExtract` | transcript 보유/추출 미실행 | `findVideosForBulkExtract()` | — | `List<Map>` (CLOB 읽음) | CLOB read 실패 | 단순 |
| `VideoService.findVideosWithoutTranscript` | 자막 미보유 영상 | `findVideosWithoutTranscript()` | — | `List<Map>` | 없음 | 단순 |
| `VideoService.updateTranscript` / `updateStatus` / `updateTitle` / `updateVideoFields` | 컬럼 갱신 | — | id + values | void | 없음 | 단순 |
| `VideoService.updateVideoRestaurantFields` | foods/evaluation/guests JSON 갱신 | — | ids + 3 JSON | void | DB JSON 제약 위반 시 throw | 단순 |
| `YouTubeService.fetchChannelVideos` | 업로드 플레이리스트 페이징 | `(channelId, after, excludeShorts)` | params | `List<Map>` | 예외 시 Search 폴백 | **복잡** |
| `YouTubeService.fetchChannelVideosViaSearch` | Search API 폴백 | 동일 | params | `List<Map>` | 파싱 실패 시 break | **복잡** |
| `YouTubeService.filterShorts` | 50개씩 duration 조회 후 60초↑ 필터 | `(videos)` | list | filtered list | 배치 실패 시 default 61 (포함) | **복잡** |
| `YouTubeService.parseDuration` | ISO8601 → 초 | `(dur)` | `PT#H#M#S` | int | regex unmatch → 0 | 단순 |
| `YouTubeService.scanChannel` | 채널 단건 스캔 + 저장 | `(channelId, full)` | id, full | `{total_fetched,new_videos,filtered}` | 채널 미존재 → null | **복잡** |
| `YouTubeService.scanAllChannels` | 활성 채널 전부 스캔 | `()` | — | int | 채널별 예외 catch+log | **복잡** |
| `YouTubeService.getTranscript` | 브라우저 → API 자막 | `(videoId, mode)` | id, mode | `TranscriptResult|null` | null 반환 | **복잡** |
| `YouTubeService.getTranscriptApi` | thoroldvix API 호출 | `(videoId, mode)` | id, mode | `TranscriptResult|null` | 예외 시 null | **복잡** |
| `YouTubeService.getTranscriptWithPage` | 기존 Page 재사용 | `(page, videoId)` | page, id | 동일 | 동일 | **복잡** |
| `YouTubeService.createBrowserSession` | Playwright+Browser+Page lifecycle | `()` | — | `BrowserSession` (`AutoCloseable`) | 실패 시 throw | **복잡** |
| `YouTubeService.fetchTranscriptFromPage` | 페이지 조작 + 세그먼트 스크롤 수집 | `(page, videoId)` | page, id | result|null | 다단계 catch | **복잡** |
| `YouTubeService.skipAds` / `selectKorean` / `loadCookies` | 페이지 보조 동작 | — | page | void | 무시 가능 | 단순 |
> 복잡 표시 함수는 외부 I/O + 다단계 폴백/상태기계 포함. 별도 `fn-*.md` 가 필요한 경우 우선순위는 `fetchTranscriptFromPage`, `bulkTranscript`, `delete`(영상 cascade), `scanChannel`.
## 8. 흐름 / 알고리즘
1. **채널 스캔 (daemon/cron):**
`scanAllChannels` → 각 채널에 `scanChannel(false)``fetchChannelVideos(channelId, latestPublishedAt, true)`. PlaylistItems(UC→UU) 50건 페이지 반복, `publishedAfter` 이전 항목 발견 시 즉시 중단. titleFilter + 기존 video_id 셋 비교 후 `saveVideosBatch` 로 신규만 insert.
2. **단건 자막 수집:**
`getTranscript``getTranscriptBrowser` (Playwright headed, cookies.txt 로드, `--disable-blink-features=AutomationControlled`). `skipAds` (광고 스킵/음소거/끝 이동), 더보기 클릭, "스크립트 표시" 버튼 탐색(aria-label → text → engagement panel), 세그먼트 0→폴링 10회×1.5s, `selectKorean` 시도, 컨테이너 스크롤 50회로 전체 수집. 실패 시 `getTranscriptApi` (manual→generated, ko→en).
3. **단건 추출:**
`VideoController.extract` → transcript 검증 → `PipelineService.processExtract(video, transcript, prompt)` 호출(상세 흐름 #270). 결과 식당 수 응답.
4. **SSE bulk-transcript:**
대상 결정(ids vs 전체) → `start{total}` emit → Pass1: 단일 `BrowserSession` 으로 순회, 각 영상 `processing{method=browser}``done` 또는 `skip``apiNeeded` 누적, 3~8s 랜덤 sleep. Pass2: `api_pass{count}` → 실패분만 `getTranscriptApi`, 결과에 따라 `done`/`error`+`status=no_transcript`. 최종 `complete{success,failed}`.
5. **SSE bulk-extract:**
대상 `findVideosForBulkExtract` (transcript 있고 추출 미실행) → 영상별 3~8s `wait``processExtract``done{restaurants}` / `error`. 총 결과 > 0 이면 `cache.flush()`.
6. **SSE remap-cuisine / remap-foods:**
대상 식당을 BATCH(20/15)로 묶어 LLM 일괄 분류 호출 → 결과 매핑 후 누락 식당은 `missed` 로 격리. 누락 항목은 size 5 배치로 최대 3회 retry. 각 단계마다 `batch_done`/`retry`/`complete` emit, 종료 시 `cache.flush()`.
7. **단일 영상 삭제 cascade:** `deleteVectorsByVideoOnly``deleteReviewsByVideoOnly``deleteFavoritesByVideoOnly``deleteRestaurantsByVideoOnly``deleteVideoRestaurants``deleteVideo` (단일 `@Transactional`).
## 9. 엣지케이스 & 에러 처리
- **PlaylistItems 실패**: try/catch → Search API 폴백. Search 도 실패하면 빈 리스트 → 신규 0.
- **publishedAfter 이전 영상 발견**: 업로드 재생목록은 시간 역순이므로 즉시 nextPage=null 로 페이지 종료 (불필요 호출 차단).
- **Shorts duration API 실패**: 해당 배치의 모든 video 는 `default=61` (포함) 으로 처리해 누락 방지.
- **transcript 없음**: 단건은 400, bulk Pass2 실패 시 status=`no_transcript`, error emit.
- **YouTube 봇 탐지**: Pass1 에서 cookies.txt 로드 + headed + 3~8s 랜덤 지연 + navigator.webdriver=false 마스킹.
- **광고 무한 루프**: `skipAds` 최대 30회 (≈30s) 후 강제 진행.
- **세그먼트 미수신**: 1.5s × 10회 폴링 후 0이면 빈 응답 → API 폴백.
- **CLOB 직렬화**: `JsonUtil.readClob` 으로 안전 변환, `@JsonRawValue` 로 JSON 컬럼은 원형 유지.
- **evaluation 형식 깨짐**: `JsonUtil.normalizeEvaluation` 으로 평문→JSON 문자열 래핑 + 300자 제한.
- **SSE 클라이언트 중단**: `emit` 내부 `Exception` 은 debug 로그만 남기고 emitter 종료. timeout(30/10분) 초과 시 자동 종료.
- **LLM 응답 누락**: remap 시 `CuisineTypes.isValid` 가 false 이면 missed 로 옮겨 retry, 끝까지 실패하면 그대로 노출 (`missed` 카운트).
- **DB IS JSON 제약**: `evaluation` 문자열 → `{`/`"` 검사 후 `JsonUtil.toJson` 래핑.
- **고아 데이터 차단**: 영상 삭제와 링크 단건 삭제 모두 `cleanupOrphan*` 호출.
- **안전 기본값**: YouTube API 통째 실패 시 빈 결과, transcript 실패 시 상태만 변경, LLM/Geocoding 실패는 식당 미생성으로 종결 (DB 손상 차단).
## 10. 테스트 계획
- **단위(JUnit5 + Mockito) — VideoService**
- `delete` 호출 순서: 6개 mapper 메서드 호출 검증 (벡터→리뷰→즐겨찾기→식당→링크→영상).
- `findDetail` null/빈 restaurants 케이스 normalizeEvaluation 호출 검증.
- `saveVideosBatch` 중복 비율 (existing set hit 시 0, 미스 시 새 ID 생성).
- **단위 — YouTubeService**
- `parseDuration` 경계값 (`PT60S=60`, `PT1M1S=61`, `PT1H=3600`, 빈 문자열=0, 오작동 입력=0).
- `filterShorts`: duration map 60 이하 제외, 누락 ID 는 기본 61 (포함).
- `fetchChannelVideos` 페이징 중단 (publishedAfter 이전 발견 즉시 break).
- `getTranscriptApi` mode 분기 (manual/generated/auto).
- **통합 (Spring + WireMock/MockWebServer)**
- YouTube API 모킹 → `scanChannel` 가 신규 N개 저장.
- LLM 모킹 → SSE `bulkExtract` 가 start/processing/done/complete 시퀀스 emit.
- `bulkTranscript` 는 Playwright 모킹이 어려우므로 `getTranscriptWithPage` 를 Mockito 로 대체.
- **E2E (수동 dev)**
- Playwright headed transcript 수집 1건 / bulk 10건.
- `DELETE /api/videos/{id}` 후 식당/링크/벡터 카운트 0 확인 (SQL).
- **인수조건 매핑**: AC1↔detail unit, AC2↔delete cascade unit+SQL, AC3↔scanChannel 통합, AC4↔fetchTranscript 통합, AC5↔bulkTranscript E2E, AC6↔모든 admin 엔드포인트 403 unit.
- **모킹/드라이런**: YouTube/Google API → MockWebServer, `OciGenAiService.chat` → Mockito stub (고정 JSON 반환).
## 11. 리스크 & 대안 검토
- **Playwright Headed (선택)**: ko 자막 정확도/봇 탐지 회피 우수. 단, Mac mini Dev 환경 의존. 대안: youtube-transcript-api (제한 많음), Whisper STT (비용/시간). → 운영(OKE)에서는 사용 안 함, 자막은 dev 에서 사전 확보.
- **SSE (선택)**: 30분 작업 진행 표시 단순. 대안: WebSocket(과한 양방향), 폴링(부정확). 트레이드오프: 한 작업이 emitter 1개 점유 → 동시 다발 사용 시 메모리 압박 (현재 admin 단독 사용 가정).
- **LLM 재분류 단일 트랜잭션 없음**: 결과 즉시 update + missed 별도 retry → 부분 성공 허용. 대안: 전부 임시 테이블 stage → 검토 후 swap (운영 부담 증가). 현재 데이터 양 < 수천 식당이라 부분 적용 수용.
- **video cascade 삭제**: 향후 ON DELETE CASCADE FK 적용 시 mapper 6단계 → 1단계로 단순화 가능 → **ADR 후보** (`adr/0001-video-cascade.md`).
- **transcript CLOB 크기**: 8000자 truncate 는 ExtractorService 가 담당, DB 는 CLOB 그대로 보관.
## 12. 미해결 질문 (Open Questions)
- `rebuildVectors` SSE 가 TODO 상태 — 전 식당 벡터 재계산 시 OCI GenAI 호출 비용/시간 산정 필요.
- `scanAllChannels` 일정 (daemon 주기? cron?) 은 #275 에서 확정 예정.
- `bulkTranscript` 가 Playwright 헤드모드를 요구해 prod 미지원 — 헤드리스 우회/Whisper STT 도입 여부.
- `evaluation`/`foods_mentioned` JSON 스키마 표준화 (현재 문자열/배열 혼재).
- Search API 폴백 quota 초과 시 사용자 메시지 (UI 표시) 부재.

View File

@@ -0,0 +1,247 @@
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: 백엔드 - 영상→식당 추출 파이프라인 (LLM+Geocoding) (#270)
> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #270 · 관련 ADR: 없음
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/ExtractorService.java`, `backend-java/src/main/java/com/tasteby/service/PipelineService.java`, `backend-java/src/main/java/com/tasteby/service/OciGenAiService.java`, `backend-java/src/main/java/com/tasteby/service/GeocodingService.java` · 테스트: TBD (현재 없음)
## 1. 목적 (Why)
유튜브 영상 자막에서 식당 정보를 LLM 으로 구조화하고, Google Maps 로 좌표/메타데이터를 보강한 뒤 DB+벡터 인덱스에 저장하여 지도/검색이 즉시 노출되도록 한다. 운영자가 단건/대량 모두 동일한 멱등 파이프라인을 호출할 수 있어야 한다.
## 2. 범위 (Scope)
- **포함**:
- LLM 프롬프트 정의(`ExtractorService.EXTRACT_PROMPT`) — 7개 필드 추출, `CuisineTypes` 표준 카테고리 강제, 한국어 응답 강제.
- OCI Generative AI (Cohere/Llama 계열) Chat & Embed 호출 + 결과 JSON 견고 파싱 (마크다운 블록 제거, 트레일링 콤마 제거, 부분 array 복구).
- Google Maps Places Text Search → Place Details → Geocoding API 폴백.
- 한국어 주소 → `나라|시/도|구/군` 형식 region 파싱.
- 추출 결과로 식당 upsert + 영상↔식당 링크 + 벡터 임베딩 저장.
- 파이프라인 상태 전이: `pending → processing → done/error`.
- **제외 (out of scope)**:
- 자막 확보(`YouTubeService`) — #269.
- 검색/추천 질의 (`VectorService.searchSimilar`) — #271.
- 식당 CRUD/병합 — #268.
- 어드민 UI 트리거 화면 — #282.
## 3. 인수조건 (Acceptance Criteria)
- [ ] `ExtractorService.extractRestaurants(title, transcript, prompt?)` 는 transcript 8000자 초과 시 머리 7000 + 꼬리 1000 으로 절단하고, LLM 응답이 JSON array/object/빈값 어떤 형태든 `List<Map>` 으로 정규화한다.
- [ ] `PipelineService.processExtract` 는 추출된 각 식당에 대해 (a) Geocoding → (b) `RestaurantService.upsert` → (c) `linkVideoRestaurant` → (d) `VectorService.saveRestaurantVectors` 순으로 실행하고, 0건이어도 영상 상태를 `done` 으로 갱신한다.
- [ ] `OciGenAiService.parseJson` 은 ```json``` 코드 블록, 트레일링 콤마, 잘린 array 를 자동 복구하며, 끝내 실패하면 `RuntimeException("JSON parse failed: ...")` 을 던진다.
- [ ] `GeocodingService.geocodeRestaurant` 는 Places Text Search 성공 시 phone/website 까지 채워 반환하고, 실패 시 Geocoding API 로 폴백하며, 둘 다 실패하면 `null` 을 반환한다 (식당은 좌표 없이 저장됨).
- [ ] `evaluation` 필드는 항상 DB 의 `IS JSON` 제약을 통과하도록 JSON 문자열 리터럴(`"..."`) 또는 객체 JSON 으로 변환된 뒤 저장된다.
- [ ] `processVideo` 가 자막 미존재 시 status=`done` 으로 종결하고, 예외 발생 시 `error` 로 마킹하여 다음 daemon 실행을 차단하지 않는다.
## 4. 컨텍스트 & 제약
- **의존성**:
- OCI Generative AI Inference SDK (`com.oracle.bmc.generativeaiinference`) — `~/.oci/config` 기반 인증, compartment/endpoint/model 은 `app.oci.*` 프로퍼티.
- Google Maps Platform — `app.google.maps-api-key` (Places + Geocoding 동일 키).
- Oracle 23ai (`restaurants`, `video_restaurants`, `restaurant_vectors` VECTOR 컬럼).
- 내부: `YouTubeService` (transcript), `RestaurantService.upsert/linkVideoRestaurant`, `VectorService.saveRestaurantVectors`, `VideoService.updateVideoFields`, `CacheService.flush`.
- 유틸: `CuisineTypes.CUISINE_LIST_TEXT`, `JsonUtil.toJson`.
- **제약**:
- OCI Chat `maxTokens=8192`, `temperature=0.0`, Embed batch 최대 96.
- Google Maps 호출당 10초 타임아웃, 일일 quota/요금 관리 필요.
- transcript 8000자 절단(LLM context 한계 회피).
- `restaurant_vectors.embedding` 은 Oracle VECTOR(float[]), `MapSqlParameterSource` 로 직접 바인딩 (#271 VectorService).
- LLM 응답이 비결정적이므로 cuisine_type 검증/재맵핑은 사후 워크플로(`remap-cuisine` SSE)에 의존.
- **가정**:
- 영상 1건당 식당 평균 1~5개, 전체 transcript 평균 ~3000자.
- OCI 인증이 없으면 (`PostConstruct` 경고) chat/embed 호출은 `IllegalStateException` → 추출 파이프라인 전체 실패 (`processVideo``error` 로 마킹).
- Google 한국어(`language=ko`) 결과를 신뢰; 해외 식당은 Places 결과 그대로 사용.
## 5. 아키텍처 개요
- 모듈/파일:
- `service/ExtractorService.java` — 프롬프트 + LLM 호출 + 결과 정규화.
- `service/PipelineService.java` — 워크플로 오케스트레이션 + 상태 전이.
- `service/OciGenAiService.java` — OCI GenAI SDK 어댑터 (chat/embed/JSON 복구).
- `service/GeocodingService.java` — Google Maps WebClient 클라이언트 + 주소 region 파싱.
- 협력: `YouTubeService` (#269), `RestaurantService` (#268), `VectorService` (#271), `VideoService` (#269), `CacheService` (#276), `util/CuisineTypes`, `util/JsonUtil`.
- I/O ↔ 순수 로직 경계:
- **I/O**: OCI GenAI 호출, Google Maps HTTP, DB 쓰기, transcript 호출.
- **순수 로직**: `EXTRACT_PROMPT` 합성, transcript 절단, `parseJson` 복구 로직, `parseRegionFromAddress`, `VectorService.buildChunks`, evaluation JSON 정규화.
```
┌─────────────┐ ┌──────────────────┐
│ daemon/cron │ ─────▶ │ PipelineService │
└─────────────┘ │ processVideo() │
└────────┬─────────┘
│ 1. transcript
┌──────────────────┐
│ YouTubeService │ (#269)
└────────┬─────────┘
│ 2. LLM extract
┌──────────────────┐ chat(prompt, 8192)
│ ExtractorService │───────────────────────▶ OCI GenAI
└────────┬─────────┘ parseJson(raw) (Chat model)
│ List<Map<String,Object>>
┌──────────── PipelineService.processExtract ────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ GeocodingService │──HTTP──▶ Google Maps │ ExtractorService │
│ placesTextSearch │ Places + Geocode │ (재사용 가능) │
│ → placeDetails │ └──────────────────┘
│ → geocode(폴백) │
└────────┬─────────┘
│ {lat,lng,formatted_address,phone,...}
┌──────────────────────────┐
│ RestaurantService.upsert │──▶ Oracle restaurants
│ + linkVideoRestaurant │──▶ Oracle video_restaurants (IS JSON)
└────────┬─────────────────┘
│ restId
┌──────────────────────────────┐ embedTexts(chunks)
│ VectorService.saveRestVectors│───────────────────────▶ OCI GenAI Embed
│ buildChunks(...) │ → Oracle restaurant_vectors
└────────┬─────────────────────┘
│ count
videoService.updateVideoFields(status=done, llmRaw)
cacheService.flush()
```
## 6. 데이터 모델
- **입력 (LLM 프롬프트 출력 = 파이프라인 입력)**:
```jsonc
[{
"name": "string (필수)",
"address": "string|null",
"region": "나라|시/도|구/군 (string|null)",
"cuisine_type": "CuisineTypes.CUISINE_LIST_TEXT 중 하나",
"price_range": "string|null",
"foods_mentioned": ["string", ...] // 최대 10, 한글
"evaluation": "string ≤ 100자",
"guests": ["string", ...]
}]
```
- **중간 데이터 (Geocoding 결과)**:
```jsonc
{
"latitude": double,
"longitude": double,
"formatted_address": "string",
"google_place_id": "string",
"business_status": "OPERATIONAL|CLOSED_TEMPORARILY|...",
"rating": double,
"rating_count": int,
"phone": "string",
"website": "string"
}
```
- **저장 구조**:
- `restaurants`: name, address(=geo.formatted_address || LLM.address), region(LLM 우선), latitude/longitude, cuisine_type, price_range, google_place_id, phone, website, business_status, rating, rating_count.
- `video_restaurants(video_id, restaurant_id, foods_mentioned IS JSON, evaluation IS JSON, guests IS JSON)`.
- `restaurant_vectors(id, restaurant_id, chunk_text CLOB, embedding VECTOR)` — `VectorService.buildChunks` 결과(name/region/cuisine/foods/evaluation/price/video_title)를 한 chunk 로 임베딩.
- `videos.status, transcript_text CLOB, llm_response CLOB`.
- **검증 규칙**:
- `name`이 null 인 식당 항목은 skip.
- transcript > 8000 → 절단.
- evaluation: 객체→`JsonUtil.toJson`, 문자열→`JsonUtil.toJson(s)` (DB IS JSON 통과 보장).
- cuisine_type 표준 목록 위반은 저장은 허용하되 `remap-cuisine` SSE 로 사후 보정.
- `transcript_text` is blank → ExtractorService 호출 전 단건 API 가 400 반환 (#269).
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `ExtractorService.getPrompt` | 기본 프롬프트 반환 | `String getPrompt()` | — | EXTRACT_PROMPT | 없음 | 단순 |
| `ExtractorService.extractRestaurants` | LLM 추출 + JSON 정규화 | `ExtractionResult extractRestaurants(title, transcript, prompt?)` | title, transcript, prompt | `{restaurants: List<Map>, rawResponse}` | catch 후 빈 결과 + log | **복잡** |
| `PipelineService.processVideo` | 단건 영상 end-to-end | `int processVideo(Map<String,Object>)` | video map | 식당 수 | 예외 → status=error | **복잡** |
| `PipelineService.processExtract` | 기존 transcript 로 LLM+저장 | `int processExtract(video, transcript, prompt?)` | 동일 | 식당 수 | 부분 실패 catch (vector save) | **복잡** |
| `PipelineService.processPending` | N건 일괄 처리 | `int processPending(int limit)` | limit | 총 식당 수 | 빈 결과시 0 | 단순 |
| `PipelineService.updateVideoStatus` (private) | 상태/transcript/llm 갱신 | `void(...)` | id, status, ... | void | DB 예외 throw | 단순 |
| `OciGenAiService.init` (PostConstruct) | OCI 클라이언트 초기화 | `void init()` | — | void | 인증 실패 시 log warn | 단순 |
| `OciGenAiService.destroy` (PreDestroy) | 클라이언트 종료 | `void destroy()` | — | void | 없음 | 단순 |
| `OciGenAiService.chat` | LLM Chat 호출 | `String chat(prompt, maxTokens)` | prompt, max | text | SDK 예외, null client | **복잡** |
| `OciGenAiService.embedTexts` | 텍스트 임베딩 (96 배치) | `List<List<Double>> embedTexts(texts)` | texts | 임베딩 매트릭스 | SDK 예외 | **복잡** |
| `OciGenAiService.embedBatch` (private) | 단일 배치 임베딩 | `(texts)` | ≤96 texts | 임베딩 | SDK 예외 | 단순 |
| `OciGenAiService.parseJson` | LLM 응답 견고 파싱 | `Object parseJson(String raw)` | raw text | List/Map/scalar | 부분 복구 후 throw | **복잡** |
| `GeocodingService.geocodeRestaurant` | Places → Geocoding 폴백 | `Map geocodeRestaurant(name, address)` | name+addr | geo map\|null | 두 단계 모두 catch | **복잡** |
| `GeocodingService.placesTextSearch` (private) | Places Text Search | `(query)` | string | map\|null | 4xx/5xx catch | **복잡** |
| `GeocodingService.placeDetails` (private) | phone/website 보강 | `(placeId)` | string | map\|null | catch | 단순 |
| `GeocodingService.geocode` (private) | Geocoding API | `(query)` | string | map\|null | catch | 단순 |
| `GeocodingService.parseRegionFromAddress` (static) | 한국 주소 → region 코드 | `(address)` | string | `한국\|시\|구` 또는 null | 빈 입력 → null | 단순 |
> 복잡 표시 함수: 모두 외부 I/O + 다단계 복구 경로. `parseJson` 과 `processExtract` 는 동작 다이어그램 별도 작성 권장.
## 8. 흐름 / 알고리즘
1. **transcript 확보 (단건 daemon 경로)**: `processVideo` → `updateVideoStatus(processing)` → `YouTubeService.getTranscript(videoId, "auto")`. null/blank → `done` 마킹 후 0 반환.
2. **transcript 컨텍스트 정리**: `ExtractorService.extractRestaurants` 입장에서 길이>8000 이면 `head(0,7000) + "...(중략)..." + tail(len-1000)` 으로 가운데를 잘라낸다.
3. **프롬프트 합성**: `customPrompt ?: EXTRACT_PROMPT` 에 `{title}`, `{transcript}` 단순 치환. `CUISINE_LIST_TEXT` 는 컴파일 타임에 포맷됨.
4. **LLM 호출**: `OciGenAiService.chat(prompt, 8192)` — `GenericChatRequest(temperature=0.0)` 으로 `UserMessage(TextContent)` 전송. 응답에서 `GenericChatResponse.choices[0].message.content[0].text` 추출 후 trim.
5. **JSON 복구**: `parseJson` 절차
1) ` ```(json)? ... ``` ` 제거.
2) `, (?=[}\]])` 트레일링 콤마 제거.
3) `mapper.readValue` 1차 시도.
4) 실패 + `[`로 시작하면 인덱스 스캔으로 객체 단위 점진 파싱 → 최대한 많이 복구. 마지막에도 0건이면 `RuntimeException`.
6. **결과 정규화**: List → 그대로, Map → 단건 List 로 감쌈, 그 외 → 빈 List + raw 반환.
7. **식당 단위 후처리 (processExtract for-each)**:
a) `name == null` skip.
b) `geocodeRestaurant(name, address)` → Places Text Search (language=ko, type=restaurant) 1순위 결과 → `place/details` 로 phone/website 보강. 실패 시 Geocoding API 폴백, 그것도 실패 시 null.
c) `data` 빌드: geo 우선 (`formatted_address`, lat/lng, place_id, business_status, rating, rating_count, phone, website), 나머지는 LLM 값.
d) `RestaurantService.upsert(data)` → restId.
e) `evaluation` 정규화 (Map→JSON, String→JSON 리터럴) 후 `linkVideoRestaurant(videoDbId, restId, foods, evaluationJson, guests)`.
f) `VectorService.buildChunks(name, restData, videoTitle)` → 한 줄로 합쳐진 단일 chunk → `saveRestaurantVectors` (Embed batch, INSERT VECTOR). 실패 시 warn 만.
g) `count++` 로그.
8. **종료**: `updateVideoStatus(done, null, rawResponse)`. `processPending` 호출자는 총합>0 이면 `cache.flush()`.
9. **상태 전이**: `pending → processing(transcript 도착) → done` (LLM 결과 0/N) | `error` (예외) | `skip`(운영 수동, #269).
10. **Geocoding 한국 주소 region 파싱**: `parseRegionFromAddress` 가 토큰 단위로 "대한민국|특별/광역/도|구/군/시" 추출, 결과는 `한국|서울|강남구` 형식. 해외 식당은 LLM 이 직접 region 을 지정하므로 보조적 용도.
## 9. 엣지케이스 & 에러 처리
- **OCI 인증 미설정**: PostConstruct 가 warn 후 chatClient/embedClient null → 호출 시 `IllegalStateException``processExtract` 가 try/catch 없이 호출 스택을 상위(`processVideo`)로 전파, 상태 `error`.
- **LLM JSON 파싱 완전 실패**: `parseJson` throw → `extractRestaurants` catch → `ExtractionResult(empty, "")`. `processExtract` 는 0건 종결 + `done`.
- **트레일링 콤마/마크다운**: 자동 sanitize.
- **잘린 array (`maxTokens` 도달)**: 부분 복구 후 사용; 잘린 항목은 폐기.
- **식당 이름 누락**: skip (저장 안 함).
- **Geocoding 모두 실패**: 식당은 `latitude/longitude=null` 로 저장 → 지도 노출 제외, 검색은 가능.
- **evaluation 형식 다양성**: Map/String 모두 처리, null 그대로 통과.
- **transcript blank**: `processVideo` 가 self-check 후 `done` 반환 (recall=0 허용).
- **Vector 저장 실패**: warn 만 (식당은 이미 저장됨, 추후 `rebuild-vectors` SSE 로 복구 — #269).
- **OCI Embed 96 한도**: 자동 분할 호출.
- **Google API rate limit (`OVER_QUERY_LIMIT`)**: status != OK 면 null 반환 → 좌표 없이 저장.
- **place details 실패**: phone/website 누락만, 좌표는 유지.
- **temperature=0.0** 이지만 모델 비결정성 일부 잔존 → 같은 영상 재실행 시 결과 약간 다를 수 있음 (멱등 보장은 `upsert` 키 = google_place_id/name+address 조합에 의존, #268).
- **안전 기본값**: 외부 I/O 실패 시 전 항목 폐기 대신 부분 저장 (좌표 없는 식당이라도 유지) — 운영자가 어드민에서 수동 보정 가능.
## 10. 테스트 계획
- **단위 — ExtractorService**
- transcript 절단 임계 (7999 → 그대로 / 8001 → head+중략+tail).
- LLM 응답 케이스: 정상 array / 단일 object / `[]` / 깨진 JSON → 각각 정상 List / 단건 List / 빈 List / 빈 List + log.
- **단위 — OciGenAiService.parseJson**
- ` ```json [...] ``` `, 트레일링 콤마, 잘린 array, 완전 비-JSON → 시나리오별 검증.
- **단위 — GeocodingService**
- Places OK + details OK → 전 필드 채움.
- Places ZERO_RESULTS → Geocoding 폴백 호출 검증 (WireMock).
- 둘 다 실패 → null.
- `parseRegionFromAddress`: 서울특별시/경기도/광역시/특별자치시/외국 주소 → 각각 기대 region 또는 null.
- **단위 — PipelineService.processExtract**
- 식당 N개 mock 추출 → upsert N회, linkVideoRestaurant N회, saveRestaurantVectors N회 호출 검증.
- vector save 예외 → 식당은 저장, warn 로그.
- name=null 항목은 skip (count 미증가).
- **통합 (Spring + WireMock)**: Google Maps 모킹 + OCI mock → `processPending(3)` 실행 후 videos.status, video_restaurants, restaurant_vectors 행수 검증.
- **드라이런**: prod 호출 비용 차단을 위해 `app.oci.*` 미설정 시 chat/embed 가 즉시 throw → `processVideo` 가 status=`error` 마킹하고 다음 영상으로 진행 (`processPending` 루프).
- **인수조건 매핑**: AC1↔ExtractorService 단위, AC2↔processExtract 통합, AC3↔parseJson 단위, AC4↔GeocodingService 단위, AC5↔evaluation 정규화 단위, AC6↔processVideo 단위.
## 11. 리스크 & 대안 검토
- **OCI GenAI 단일 벤더 잠금**: 대안 OpenAI/Anthropic. 트레이드오프: OCI 는 동일 테넌시 내 IAM 통합/내한권 결제. → **ADR 후보** (`adr/0002-llm-provider.md`).
- **transcript 절단 (선택)**: 8000자 hard cut. 대안: 청크 + map-reduce 요약 (지연/비용↑) 또는 더 큰 context 모델. 현재 영상 평균 < 8000자라 단순 cut 채택.
- **Geocoding Places vs Geocoding 폴백 순서**: Places 가 phone/rating 까지 주므로 1순위. 대안: 카카오/네이버 로컬 API (한국 정확도↑) — 향후 옵션.
- **벡터 chunk 1개/식당**: 검색 정확도 vs 비용 트레이드오프. 대안: 메뉴별 분할 chunk → 임베딩 수 N배, FETCH 시 중복 제거 필요. 현재 토픽이 좁아 단일 chunk 유지.
- **temperature=0.0**: 재현성↑. 대안: 약간 ↑ 시 다양한 메뉴 추출 가능 — 일관성 우선.
- **evaluation JSON 강제**: DB CHECK 제약을 만족시키는 가장 단순한 방법 (JSON 리터럴 wrap). 향후 정형화(`{summary, rating, ...}`) 이전 가능.
- **부분 실패 허용**: 식당 일부만 저장되는 시나리오 수용 → 운영자 검토 비용. 대안: 전부 임시 영역 → 검토 후 swap (구현 복잡).
## 12. 미해결 질문 (Open Questions)
- `cuisine_type` 표준 목록 위반 비율이 얼마나 되는가? 사전 검증(`CuisineTypes.isValid`) 후 자동 폴백을 LLM 단계에서 적용할지.
- transcript 8000자 cut 대신 슬라이딩 윈도우 multi-pass 요약 도입 여부 (비용/정확도 검토).
- Geocoding 결과 중 `business_status=CLOSED_*` 인 식당의 처리 정책 (자동 제외 vs 표시).
- 영상에 동일 식당이 중복 언급될 때 upsert 키와 link 중복 방지 (현재 `RestaurantService.upsert` 키 정책에 의존).
- Embed cosine 임계(`maxDistance`)는 #271 에서 0.57 — 학습 데이터 누적 후 재조정 필요.
- 다국어 영상 (예: 일본 식당) 의 region 파싱 강건성 (현재 한국 주소 패턴 위주).

View File

@@ -0,0 +1,201 @@
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: 백엔드 - 검색/벡터 추천 (#271)
> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #271 · 관련 ADR: 없음
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/SearchService.java`, `backend-java/src/main/java/com/tasteby/service/VectorService.java`, `backend-java/src/main/java/com/tasteby/controller/SearchController.java` · 테스트: TBD (현재 없음)
## 1. 목적 (Why)
사용자가 식당명/메뉴/지역 키워드로 빠르게 후보를 찾고, 의미 기반 추천(예: "혼술하기 좋은 이자카야")도 받을 수 있도록 키워드 SQL + Oracle 23ai VECTOR 코사인 거리 검색을 동일 엔드포인트로 제공한다.
## 2. 범위 (Scope)
- **포함**:
- `GET /api/search?q=&mode=&limit=` 단일 엔드포인트.
- 모드: `keyword`(기본, LIKE), `semantic`(벡터), `hybrid`(키워드 + 벡터 union).
- 결과 캐싱 (`CacheService`, key = `search:q=..:m=..:l=..`).
- 채널명 부착(`attachChannels`) — 검색 결과에 어떤 채널들이 다뤘는지 표시.
- 벡터 인덱스 운영용 API: 추출 파이프라인에서 호출되는 `saveRestaurantVectors`, 검색용 `searchSimilar`.
- **제외 (out of scope)**:
- 식당 상세/지도 노출 — #268/#278.
- 벡터 재생성 SSE (`rebuild-vectors`) — #269 (TODO).
- 사용자별 개인화 추천/로그.
- 채널 마스터 데이터 — #273.
- 임베딩 모델 학습/튜닝.
## 3. 인수조건 (Acceptance Criteria)
- [ ] `GET /api/search?q=족발&mode=keyword&limit=20``restaurants.name/foods_mentioned/...``%족발%` LIKE 매칭된 식당을 최대 limit(상한 100)개 반환하고, 각 결과의 `channels` 배열에 출연 채널명이 채워진다.
- [ ] `mode=semantic` 호출 시 OCI Embed 로 쿼리 임베딩 → `VECTOR_DISTANCE(... COSINE)``maxDistance ≤ 0.57` 인 chunk 상위 `max(30, limit*3)` 개를 가져와, restaurant_id 중복 제거 후 좌표 있는 식당만 limit 개 반환한다.
- [ ] `mode=hybrid` 는 키워드 결과 우선 + 의미 결과를 뒤에 union 하며, 동일 식당 중복 제거 후 limit 로 컷한다.
- [ ] 동일 (q, mode, limit) 두 번째 호출은 Redis 캐시에서 즉시 반환 (DB/OCI 호출 0회).
- [ ] semantic 호출 중 OCI 실패 시 keyword 결과로 자동 폴백하며 500 을 던지지 않는다.
- [ ] `VectorService.saveRestaurantVectors` 가 chunks 리스트를 96개 단위 배치 임베딩 후 한 INSERT/chunk 로 Oracle VECTOR 컬럼에 저장한다.
## 4. 컨텍스트 & 제약
- **의존성**:
- Oracle 23ai VECTOR 타입 + `VECTOR_DISTANCE(..., COSINE)` 함수.
- `OciGenAiService.embedTexts` (Cohere/embed-v4 등, 96 배치).
- `RestaurantService.findById` — semantic 결과 1차 행 조회.
- `CacheService` (Redis) — 직렬화는 Jackson ObjectMapper(local 인스턴스).
- `SearchMapper.keywordSearch`, `SearchMapper.findChannelsByRestaurantIds`.
- **제약**:
- `limit ≤ 100` (Controller 가드).
- 임베딩 비용 → 동일 쿼리 캐시 hit 시 0 호출, miss 시 1 호출.
- VECTOR 컬럼 바인딩은 `NamedParameterJdbcTemplate + float[]` 직접 바인딩 (MyBatis 미지원이라 JDBC 사용).
- hybrid mode 는 union 후 limit 만 적용 — 가중치 랭킹은 미구현 (단순 keyword 우선).
- **가정**:
- 검색 빈도는 식당 추출보다 훨씬 잦지만 임베딩 호출은 캐시로 대부분 흡수된다.
- 식당 1건당 vector chunk 1개 (`VectorService.buildChunks`) — 좌표 없는 식당은 semantic 결과에서 자동 제거.
- cosine distance 임계 0.57 은 운영 관측치 기반 (조정 가능).
## 5. 아키텍처 개요
- 모듈/파일:
- `controller/SearchController.java` — REST 엔드포인트, limit clamp.
- `service/SearchService.java` — 모드 분기, 캐시, 채널 부착.
- `service/VectorService.java` — 임베딩 + Oracle VECTOR 검색/저장 (JDBC).
- `mapper/SearchMapper.java` (+ XML) — `keywordSearch`, `findChannelsByRestaurantIds`.
- 협력: `OciGenAiService` (#270), `RestaurantService` (#268), `CacheService` (#276).
- I/O ↔ 순수 로직 경계:
- **I/O**: Oracle (LIKE + VECTOR), Redis 캐시, OCI Embed.
- **순수 로직**: 모드 분기, 중복 제거(LinkedHashSet), keyword 우선 union, `buildChunks` 텍스트 합성, 좌표 필터(`r.getLatitude() != null`).
```
┌────────────────────────┐
GET /api/search?q=&mode=&limit= │ SearchController │
────────────────────────────────▶ search(q, mode, limit) │
│ limit = min(limit,100) │
└──────────┬─────────────┘
┌──────────────────────────────────┐
│ SearchService.search │
│ cache.get("search:q=..:m=..") │ hit ──▶ return List<Restaurant>
└──────────┬───────────────────────┘ miss
┌────────────┬─────────┴────────────┬─────────────┐
▼ ▼ ▼ ▼
keywordSearch semanticSearch hybrid: cache.set
│ │ kw + sem union │
▼ ▼ │
SearchMapper VectorService.searchSimilar │
LIKE %q% ├── OciGenAiService.embedTexts(query) │
attachChannels │ → float[] qvec │
├── jdbc.query VECTOR_DISTANCE(... COSINE) │
│ ≤0.57, ORDER BY dist FETCH FIRST k │
└── RestaurantService.findById(rid) [coord!=null]
◀────────────── Response
```
## 6. 데이터 모델
- **입력**:
- 쿼리 파라미터: `q: string (필수)`, `mode ∈ {keyword, semantic, hybrid}`(기본 keyword), `limit: int (기본 20, 상한 100)`.
- **출력**: `List<Restaurant>` — id, name, address, region, latitude, longitude, cuisineType, priceRange, phone, website, googlePlaceId, businessStatus, rating, ratingCount, updatedAt, `channels: string[]` (검색 결과에만 채움), `foodsMentioned` (옵션).
- **벡터 인덱스 저장 (`restaurant_vectors`)**:
- `id` 32자 UUID(hex upper), `restaurant_id` FK, `chunk_text` CLOB, `embedding` VECTOR.
- chunk 본문 예시:
```
식당: <name>
지역: <region>
음식 종류: <cuisine_type>
메뉴: a, b, c
평가: <evaluation>
가격대: <price>
영상: <video_title>
```
- **캐시 키**: `search:q=<q>:m=<mode>:l=<limit>` (`CacheService.makeKey`). 값은 `List<Restaurant>` Jackson JSON.
- **검증 규칙**:
- `q` 가 비어 있으면 Controller 가드(현재 미존재) — 빈 문자열은 `%%` LIKE 로 모든 식당 매칭되므로 클라이언트에서 빈 쿼리 차단 권장. (Open Question 참조)
- limit > 100 → 100 으로 clamp.
- semantic 결과는 `latitude != null` 인 식당만 — 지도 노출 가능한 후보로 한정.
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `SearchController.search` | REST 엔드포인트 + limit clamp | `List<Restaurant> search(q, mode, limit)` | q, mode, limit | List<Restaurant> | 없음 | 단순 |
| `SearchService.search` | 모드 분기 + 캐싱 | `List<Restaurant> search(q, mode, limit)` | 동일 | 동일 | 캐시 직렬화 catch | **복잡** |
| `SearchService.keywordSearch` (private) | LIKE 검색 + 채널 부착 | `(q, limit)` | %q% pattern | List<Restaurant> | 빈 결과 빈 list | 단순 |
| `SearchService.semanticSearch` (private) | 벡터 검색 + 식당 조회 | `(q, limit)` | q, limit | List<Restaurant> | catch → keyword 폴백 | **복잡** |
| `SearchService.attachChannels` (private) | restaurant_id ↔ 채널명 부착 | `(restaurants)` | List | void (mutate) | 매핑 누락 시 빈 list | 단순 |
| `VectorService.searchSimilar` | OCI embed + Oracle VECTOR 질의 | `(query, topK, maxDistance)` | text, k, dist | `List<Map>` (restaurant_id, chunk_text, distance) | embed 실패 throw | **복잡** |
| `VectorService.saveRestaurantVectors` | chunk 배열 임베딩 + INSERT | `(restaurantId, chunks)` | rid, chunks | void | chunk 별 update 예외 throw | **복잡** |
| `VectorService.buildChunks` (static) | 식당 데이터 → 임베딩 텍스트 | `(name, data, videoTitle)` | name + Map + title | `List<String>`(1) | 없음 | 단순 |
> 복잡 표시 함수는 외부 I/O + 폴백 또는 비-MyBatis 경로(JDBC + VECTOR 바인딩). 별도 `fn-*.md` 우선순위: `searchSimilar`, `semanticSearch`.
## 8. 흐름 / 알고리즘
1. **요청 진입**: Controller 가 `limit > 100` 이면 100 으로 clamp → `SearchService.search(q, mode, limit)` 호출.
2. **캐시 조회**: key=`search:q=<q>:m=<mode>:l=<limit>` → `cache.getRaw` hit 시 Jackson 역직렬화 후 즉시 반환. 직렬화 예외는 무시하고 본 로직 진행.
3. **모드 분기**:
- `keyword` (default): `SearchMapper.keywordSearch("%q%", limit)` → 결과에 `attachChannels` 적용.
- `semantic`: `VectorService.searchSimilar(q, max(30, limit*3), 0.57)` → 결과의 `restaurant_id` 를 `LinkedHashSet` 으로 중복 제거 → `restaurantService.findById(rid)` 로 행 조회, `latitude != null` 인 것만 limit 개까지 누적.
- `hybrid`: keyword 결과 + semantic 결과를 순서대로 union (`HashSet seen` 으로 중복 제거), limit 초과시 subList(0, limit). (현재 채널 부착은 keyword 결과에만 적용됨.)
4. **벡터 검색 내부 (`searchSimilar`)**:
a) `OciGenAiService.embedTexts([query])` → `List<List<Double>>` → 첫 임베딩을 `float[]` 로 변환.
b) SQL:
```sql
SELECT rv.restaurant_id, rv.chunk_text,
VECTOR_DISTANCE(rv.embedding, :qvec, COSINE) AS dist
FROM restaurant_vectors rv
WHERE VECTOR_DISTANCE(rv.embedding, :qvec2, COSINE) <= :max_dist
ORDER BY dist
FETCH FIRST :k ROWS ONLY
```
`:qvec`/`:qvec2` 동일 배열 두 번 바인딩 (SELECT 와 WHERE 에 각각 사용).
c) RowMapper: `RESTAURANT_ID`, `CHUNK_TEXT`(CLOB → `JsonUtil.readClob`), `DIST`.
5. **캐시 저장**: 최종 결과를 `cache.set(key, result)` (Jackson JSON 직렬화, CacheService 가 TTL 관리 — #276).
6. **벡터 저장 (`saveRestaurantVectors`)**: chunks 빈 리스트면 즉시 종료. `embedTexts(chunks)` 후 chunks.size 만큼 반복하며 각 row 에 새 UUID + 변환된 float[] + chunk_text 를 `INSERT` (단건 update). 예외는 호출자(`PipelineService`) 에서 warn 처리.
7. **채널 부착**: `SearchMapper.findChannelsByRestaurantIds(ids)` → row 의 `restaurant_id`(소문자 또는 `RESTAURANT_ID` 대문자) 두 키 모두 지원 → `Map<restId, List<channelName>>` 구성 → 각 Restaurant 의 `channels` 필드 set (없으면 빈 리스트).
## 9. 엣지케이스 & 에러 처리
- **빈 쿼리**: `q=""` → keyword 는 모든 식당 매칭(LIKE `%%`), semantic 은 의미 약함. 현재 Controller 가드 없음 → Open Questions 참조.
- **특수문자/와일드카드**: q 에 `%`/`_` 가 있으면 LIKE 부작용. 현재 escape 미적용 (Open Question).
- **OCI Embed 미설정**: `IllegalStateException` → `semanticSearch` catch → keyword 폴백, 사용자에겐 200 응답.
- **임베딩 빈 결과**: `searchSimilar` 가 빈 list 반환 → semantic 결과 0 → `keywordSearch` 폴백은 발생하지 않음 (현재 코드: semantic 0이면 빈 결과 반환 가능). hybrid 모드는 키워드 결과로 채워짐.
- **좌표 없는 식당**: semantic 결과에서 자동 제외 (지도 무관 검색 시 누락 가능 — 의도).
- **캐시 직렬화 실패**: getRaw 후 `mapper.readValue` 예외는 무시 후 DB 재조회.
- **VECTOR 거리 임계 미달**: `WHERE dist <= 0.57` 로 0건 가능 → 빈 list. 임계 낮추거나 키워드 사용 권장.
- **채널 매핑 row 키 대소문자 차이**: row.getOrDefault 로 두 케이스 모두 처리 — Oracle 컬럼 대문자/lowerKeys 미적용 시 대비.
- **CLOB chunk_text**: `JsonUtil.readClob` 으로 안전 변환.
- **DB 연결 실패**: `jdbc.query` 예외 → SearchService 의 `semanticSearch` catch → keyword 폴백.
- **부분 결과**: hybrid 에서 keyword 만 결과가 있고 semantic 이 throw 시, keyword 결과만 반환되도록 catch 위치는 `semanticSearch` 내부 → 그대로 빈 리스트가 hybrid union 의 절반으로 사용됨 (장애 격리).
- **안전 기본값**: 모든 외부 실패는 keyword 결과 또는 빈 list 로 수렴; 500 응답을 피한다.
## 10. 테스트 계획
- **단위 — SearchService**
- 캐시 hit → mapper/vector 미호출, 결과 동일.
- 캐시 miss + keyword 모드 → `keywordSearch` 1회, `attachChannels` 호출.
- semantic 모드 → vector 결과 K*3, dedup, 좌표 없는 식당 제외 검증.
- hybrid 중복 제거 순서 (kw 우선) 검증.
- vector 예외 → keyword 폴백.
- **단위 — VectorService**
- `buildChunks` 입력 누락 필드 (region/cuisine/foods 등) 가 출력에서 자연스럽게 생략.
- `saveRestaurantVectors` empty chunks → no-op.
- **통합 (Spring + Testcontainers Oracle 또는 in-memory mock)**
- `restaurant_vectors` 에 샘플 데이터 삽입 → `searchSimilar("족발", 10, 0.57)` 거리 정렬 검증.
- `keywordSearch` LIKE 매칭 + 채널 부착.
- **계약 테스트**
- `GET /api/search?q=&limit=200` → limit clamp 100.
- mode 오타 → default(keyword).
- **드라이런/모킹**: OCI Embed → 고정 vector 반환 stub. Oracle VECTOR 함수 모킹 어려움 → Testcontainers 23ai (free profile) 사용 권장.
- **인수조건 매핑**: AC1↔keyword 통합, AC2↔searchSimilar 통합, AC3↔hybrid 단위, AC4↔캐시 단위, AC5↔폴백 단위, AC6↔saveRestaurantVectors 통합.
## 11. 리스크 & 대안 검토
- **Oracle 23ai VECTOR (선택)**: DB 내장이라 별도 인프라 불필요. 대안: pgvector, Pinecone, Qdrant — 운영 부담↑. → 트레이드오프: 23ai 라이선스/리전 의존.
- **NamedParameterJdbcTemplate 직접 사용**: MyBatis 가 VECTOR 직렬화 미지원 → JDBC 가 가장 단순. 대안: TypeHandler 작성 (구현 비용). 현 단계에서 단일 메서드라 직접 JDBC 유지.
- **단일 chunk/식당**: 비용 절감 + 단순. 대안: 메뉴/리뷰/장르 분리 chunk → recall↑, 비용/저장↑. 후속 ADR 후보.
- **hybrid union 단순 합치기**: 가중치 랭킹 부재 → semantic 일치 식당이 뒤에 묻힘. 대안: RRF (Reciprocal Rank Fusion) 또는 distance/score 정규화 후 정렬.
- **maxDistance=0.57 하드코딩**: 운영 관측치 변동 시 코드 수정 필요. 대안: 환경변수/설정으로 빼기.
- **캐시 무효화**: 식당/링크 변경 시 `CacheService.flush()` 전체 플러시 (현재 정책) → 콜드스타트 비용. 대안: 키 prefix 별 무효화.
- **빈 쿼리 가드 부재**: 운영 사고 시 모든 식당 반환 → 응답 크기 폭발. 트레이드오프: 가드 추가 (저비용).
## 12. 미해결 질문 (Open Questions)
- 빈 쿼리/공백 쿼리는 400 반환할지, 인기 식당 fallback 으로 응답할지.
- LIKE 와일드카드(`%`/`_`) escape 정책.
- hybrid 모드 랭킹 알고리즘 (RRF 도입 여부, semantic 가중치).
- semantic 모드에서 좌표 없는 식당도 노출할지 (검색 결과 vs 지도 마커 분리).
- 임계 `maxDistance=0.57` 의 모니터링/튜닝 방법 (사용자 클릭률 로그 필요).
- `restaurant_vectors` 중복(같은 식당 여러 chunk) 정책 — 현재 `saveRestaurantVectors` 가 추가만 함, 재추출 시 누적될 가능성.
- 검색 결과에 `foods_mentioned`/거리/평점 동시 노출 방식 (#278 와 합의 필요).

Some files were not shown because too many files have changed in this diff Show More