Compare commits

..

10 Commits

Author SHA1 Message Date
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
30 changed files with 505 additions and 68 deletions

2
.gitignore vendored
View File

@@ -18,3 +18,5 @@ k8s/secrets.yaml
# OS / misc # OS / misc
.DS_Store .DS_Store
backend/cookies.txt backend/cookies.txt
backend-java/cookies.txt
**/cookies.txt

View File

@@ -6,6 +6,66 @@
## 2026-06-15 ## 2026-06-15
### 🧱 P4-1 백엔드 CRUD 결함 (v0.1.20)
- #294: MemoService/ReviewService 동시성 DuplicateKeyException 가드, rating 0~5 검증, getAvgRating NVL
- #295: 유니크 충돌 typed exception, channel_id "UC..." 형식 명시 분기, findByChannelId 컬럼 보완, body null 가드
- #290: @PreDestroy executor shutdown, 캐시 silent → log.warn + cache.del, tabling/catchtable URL 스킴 화이트리스트
- 후속 분리: #332(#290), #333(#295), #334(#294) — DTO/DDG/세분화/테스트
- Refs: #290 #294 #295 (close)
### 🔍 #293 검색/벡터 결함 7건 (v0.1.19)
- SearchController: q 빈값 400 가드 (`%%` 응답 폭발 차단)
- SearchService: LIKE 와일드카드 escape (%, _, \), hybrid 모드에서 sem 결과에도 채널 부착
- SearchService: ObjectMapper/TypeReference static 재사용, 알 수 없는 mode warn 로그
- SearchService: maxDistance를 @Value("${app.search.max-distance:0.57}") 외부화 (env SEARCH_MAX_DISTANCE)
- SearchMapper.xml: LIKE 절에 ESCAPE '\' 추가
- VectorService: embeddings null/empty 가드 (NPE 차단)
- 후속 분리: #331 (batch insert + 테스트)
- Refs: #293 (close)
### 🛠 #304+#323 어드민 LLM 검증 UI + 공통 유틸 (v0.1.18)
- 신규 frontend/src/lib/admin-utils.ts:
- getAdminToken / authHeaders / consumeSseStream
- api.ts: Restaurant 타입에 hidden/hidden_reason/verified_at + verify/setRestaurantHidden API 4개
- RestaurantsPanel:
- 헤더: "미검증 N건 + LLM 검증" 버튼
- 테이블: 검증 컬럼 (숨김/OK/미검증 배지 + 클릭으로 토글)
- colSpan 7로 수정
- 후속 분리: #329 (admin 전체 파일 분리 + localStorage/SSE 11+곳 통일)
- Refs: #304 #323 #322 (close)
### 🔧 #291+#292 백엔드 결함 일괄 수정 (v0.1.17)
- ExtractorService: transcript null/blank 가드 (NPE 방지)
- PipelineService.processExtract: 진입 시 status='processing' 명시 전이 (SSE/사용자 가시성)
- PipelineService: geocode 실패 시 좌표/place_id/주소 컬럼을 data에 put하지 않아 upsert COALESCE 보존 의도 명확화
- GeocodingService.parseRegionFromAddress: 빈 토큰을 region 문자열에서 제거 ('한국||구' 깨짐 방지)
- VideoService.saveVideosBatch: @Transactional 추가 → batch insert 원자성
- .gitignore: backend-java/cookies.txt 및 **/cookies.txt
- 후속 분리: #325 (#291 잔여 MINOR), #326 (parseJson 최적화 + #292 MINOR)
- Refs: #291 #292 (close)
### 🧹 #322 LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김 (v0.1.16)
- DB 마이그레이션: restaurants에 hidden(NUMBER(1)), hidden_reason(VARCHAR2(120)), verified_at(TIMESTAMP) + idx_restaurants_hidden
- 도메인/Mapper/Service 확장: includeHidden 옵션, updateVerification, findUnverified 등
- 신규 RestaurantVerifyService:
- verifyAsync (신규 등록 자동 검증)
- verifyAll (백필, 식당당 200ms sleep)
- parseVerifyResponse (안전 기본값: 파싱 실패 시 valid=true → hidden 유지)
- PipelineService.processExtract 끝에 verifyAsync(restId) 자동 호출
- AdminRestaurantController 신규 (requireAdmin):
- GET /api/admin/restaurants/verify/pending
- POST /api/admin/restaurants/verify/all?batchSize=10
- POST /api/admin/restaurants/{id}/verify
- PATCH /api/admin/restaurants/{id}/hidden
- 어드민 UI는 후속 #323으로 분리
- Refs: #322 (close)
### 📺 #291 publishedAfter 페이징 조기 종료 버그 (v0.1.15) + dev/prod 데몬 분리
- YouTubeService.fetchChannelVideos: stopPaging 플래그로 조기 종료 정확화 → 백필 효율 + YouTube API quota 절약
- DaemonScheduler에 app.daemon.enabled (env DAEMON_ENABLED) 플래그
- dev/prod가 같은 Oracle ATP를 공유하는 환경에서 dev DAEMON_ENABLED=false로 중복 폴링 차단
- Refs: #291 #275 #321
### ♿ #301+#302 모달 접근성 + race condition + 필터 상태 동기화 (v0.1.14) ### ♿ #301+#302 모달 접근성 + race condition + 필터 상태 동기화 (v0.1.14)
- 공통 훅 `frontend/src/lib/hooks/useModalA11y.ts` 신규 (useEscapeKey, useFocusTrap, useBodyScrollLock) - 공통 훅 `frontend/src/lib/hooks/useModalA11y.ts` 신규 (useEscapeKey, useFocusTrap, useBodyScrollLock)
- BottomSheet/FilterSheet: role='dialog', aria-modal, aria-label/labelledby, ESC 닫기, focus trap - BottomSheet/FilterSheet: role='dialog', aria-modal, aria-label/labelledby, ESC 닫기, focus trap

View File

@@ -7,6 +7,7 @@ import com.tasteby.security.AuthUtil;
import com.tasteby.service.CacheService; import com.tasteby.service.CacheService;
import com.tasteby.service.ChannelService; import com.tasteby.service.ChannelService;
import com.tasteby.service.YouTubeService; import com.tasteby.service.YouTubeService;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -52,15 +53,20 @@ public class ChannelController {
String channelId = body.get("channel_id"); String channelId = body.get("channel_id");
String channelName = body.get("channel_name"); String channelName = body.get("channel_name");
String titleFilter = body.get("title_filter"); String titleFilter = body.get("title_filter");
// #295 — body 필수값 가드 (NOT NULL 컬럼에 빈 값 들어가 500 나는 것 방지)
if (channelId == null || channelId.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "channel_id는 필수입니다");
}
if (channelName == null || channelName.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "channel_name은 필수입니다");
}
try { try {
String id = channelService.create(channelId, channelName, titleFilter); String id = channelService.create(channelId, channelName, titleFilter);
cache.flush(); cache.flush();
return Map.of("id", id, "channel_id", channelId); return Map.of("id", id, "channel_id", channelId);
} catch (Exception e) { } catch (DataIntegrityViolationException e) {
if (e.getMessage() != null && e.getMessage().toUpperCase().contains("UQ_CHANNELS_CID")) { // #295 — 유니크 충돌을 메시지 문자열 매칭 대신 typed 예외로 감지 (제약명 변경에도 견고).
throw new ResponseStatusException(HttpStatus.CONFLICT, "Channel already exists"); throw new ResponseStatusException(HttpStatus.CONFLICT, "Channel already exists");
}
throw e;
} }
} }

View File

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

View File

@@ -7,6 +7,7 @@ import com.tasteby.security.AuthUtil;
import com.tasteby.service.CacheService; import com.tasteby.service.CacheService;
import com.tasteby.service.GeocodingService; import com.tasteby.service.GeocodingService;
import com.tasteby.service.RestaurantService; import com.tasteby.service.RestaurantService;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -47,6 +48,12 @@ public class RestaurantController {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
// #290 — Bean 종료 시 virtual thread executor를 정리하여 리소스 누수 방지.
@PreDestroy
public void shutdownExecutor() {
executor.shutdown();
}
@GetMapping @GetMapping
public List<Restaurant> list( public List<Restaurant> list(
@RequestParam(defaultValue = "100") int limit, @RequestParam(defaultValue = "100") int limit,
@@ -61,7 +68,7 @@ public class RestaurantController {
if (cached != null) { if (cached != null) {
try { try {
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {}); return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
} catch (Exception ignored) {} } catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
} }
var result = restaurantService.findAll(limit, offset, cuisine, region, channel); var result = restaurantService.findAll(limit, offset, cuisine, region, channel);
cache.set(key, result); cache.set(key, result);
@@ -75,7 +82,7 @@ public class RestaurantController {
if (cached != null) { if (cached != null) {
try { try {
return objectMapper.readValue(cached, Restaurant.class); return objectMapper.readValue(cached, Restaurant.class);
} catch (Exception ignored) {} } catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
} }
var r = restaurantService.findById(id); var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found"); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
@@ -241,6 +248,10 @@ public class RestaurantController {
var r = restaurantService.findById(id); var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
String url = body.get("tabling_url"); String url = body.get("tabling_url");
// #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
if (url != null && !url.isBlank() && !url.startsWith("https://tabling.co.kr/")) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "테이블링 URL은 https://tabling.co.kr/ 만 허용");
}
restaurantService.update(id, Map.of("tabling_url", url != null ? url : "")); restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
cache.flush(); cache.flush();
return Map.of("ok", true); return Map.of("ok", true);
@@ -367,6 +378,12 @@ public class RestaurantController {
var r = restaurantService.findById(id); var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
String url = body.get("catchtable_url"); String url = body.get("catchtable_url");
// #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
if (url != null && !url.isBlank()
&& !url.startsWith("https://app.catchtable.co.kr/")
&& !url.startsWith("https://www.catchtable.co.kr/")) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "캐치테이블 URL은 https://(app|www).catchtable.co.kr/ 만 허용");
}
restaurantService.update(id, Map.of("catchtable_url", url != null ? url : "")); restaurantService.update(id, Map.of("catchtable_url", url != null ? url : ""));
cache.flush(); cache.flush();
return Map.of("ok", true); return Map.of("ok", true);
@@ -379,7 +396,7 @@ public class RestaurantController {
if (cached != null) { if (cached != null) {
try { try {
return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {}); return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {});
} catch (Exception ignored) {} } catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
} }
var r = restaurantService.findById(id); var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found"); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");

View File

@@ -39,7 +39,7 @@ public class ReviewController {
@PathVariable String restaurantId, @PathVariable String restaurantId,
@RequestBody Map<String, Object> body) { @RequestBody Map<String, Object> body) {
String userId = AuthUtil.getUserId(); String userId = AuthUtil.getUserId();
double rating = ((Number) body.get("rating")).doubleValue(); double rating = requireRating(body.get("rating"));
String text = (String) body.get("review_text"); String text = (String) body.get("review_text");
LocalDate visitedAt = body.get("visited_at") != null LocalDate visitedAt = body.get("visited_at") != null
? LocalDate.parse((String) body.get("visited_at")) : null; ? LocalDate.parse((String) body.get("visited_at")) : null;
@@ -51,8 +51,7 @@ public class ReviewController {
@PathVariable String reviewId, @PathVariable String reviewId,
@RequestBody Map<String, Object> body) { @RequestBody Map<String, Object> body) {
String userId = AuthUtil.getUserId(); String userId = AuthUtil.getUserId();
Double rating = body.get("rating") != null Double rating = body.get("rating") != null ? requireRating(body.get("rating")) : null;
? ((Number) body.get("rating")).doubleValue() : null;
String text = (String) body.get("review_text"); String text = (String) body.get("review_text");
LocalDate visitedAt = body.get("visited_at") != null LocalDate visitedAt = body.get("visited_at") != null
? LocalDate.parse((String) body.get("visited_at")) : null; ? LocalDate.parse((String) body.get("visited_at")) : null;
@@ -94,4 +93,18 @@ public class ReviewController {
public List<Restaurant> myFavorites() { public List<Restaurant> myFavorites() {
return reviewService.getUserFavorites(AuthUtil.getUserId()); return reviewService.getUserFavorites(AuthUtil.getUserId());
} }
/**
* #294 — rating 검증: null/비숫자/범위 외 입력은 400.
*/
private static double requireRating(Object raw) {
if (!(raw instanceof Number n)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "rating은 숫자여야 합니다");
}
double v = n.doubleValue();
if (v < 0.0 || v > 5.0 || Double.isNaN(v)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "rating은 0.0 ~ 5.0 범위여야 합니다");
}
return v;
}
} }

View File

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

View File

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

View File

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

View File

@@ -27,8 +27,15 @@ public class CacheService {
this.redis = redis; this.redis = redis;
this.mapper = mapper; this.mapper = mapper;
this.ttl = Duration.ofSeconds(ttlSeconds); this.ttl = Duration.ofSeconds(ttlSeconds);
try { // #276 — ping 연결 자원 누수 방지: try-with-resources
redis.getConnectionFactory().getConnection().ping(); var factory = redis.getConnectionFactory();
if (factory == null) {
log.warn("Redis ConnectionFactory is null, caching disabled");
disabled = true;
return;
}
try (var conn = factory.getConnection()) {
conn.ping();
log.info("Redis connected"); log.info("Redis connected");
} catch (Exception e) { } catch (Exception e) {
log.warn("Redis unavailable ({}), caching disabled", e.getMessage()); log.warn("Redis unavailable ({}), caching disabled", e.getMessage());
@@ -37,6 +44,13 @@ public class CacheService {
} }
public String makeKey(String... parts) { public String makeKey(String... parts) {
// #276 — null/빈 파트로 "tasteby::" 같은 잘못된 키 생성 방지
if (parts == null || parts.length == 0) {
throw new IllegalArgumentException("makeKey requires at least one part");
}
for (String p : parts) {
if (p == null) throw new IllegalArgumentException("makeKey parts must not be null");
}
return PREFIX + String.join(":", parts); return PREFIX + String.join(":", parts);
} }
@@ -85,4 +99,14 @@ public class CacheService {
log.debug("Cache flush error: {}", e.getMessage()); log.debug("Cache flush error: {}", e.getMessage());
} }
} }
// #290 — 단일 키 삭제 (캐시 역직렬화 실패 시 자동 evict 등에 사용)
public void del(String key) {
if (disabled) return;
try {
redis.delete(key);
} catch (Exception e) {
log.debug("Cache del error: {}", e.getMessage());
}
}
} }

View File

@@ -27,11 +27,16 @@ public class ChannelService {
} }
public boolean deactivate(String channelId) { public boolean deactivate(String channelId) {
// Try deactivate by channel_id first, then by DB id if (channelId == null || channelId.isBlank()) return false;
int rows = mapper.deactivateByChannelId(channelId); // #295 — 입력 형식으로 명시적 분기:
if (rows == 0) { // "UC..."(24 chars) 형식 → YouTube channel_id로 비활성화
rows = mapper.deactivateById(channelId); // 그 외(32-char hex UUID 등) → DB id로 비활성화
} // 이전: channel_id 시도 → 0이면 id 시도. 우연히 UC가 hex와 같을 확률은 0이지만
// 가독성/의도 명확성 + 잘못된 폴백 차단을 위해 명시화.
boolean looksLikeYouTubeId = channelId.startsWith("UC") && channelId.length() == 24;
int rows = looksLikeYouTubeId
? mapper.deactivateByChannelId(channelId)
: mapper.deactivateById(channelId);
return rows > 0; return rows > 0;
} }

View File

@@ -2,7 +2,9 @@ package com.tasteby.service;
import com.tasteby.domain.DaemonConfig; import com.tasteby.domain.DaemonConfig;
import com.tasteby.mapper.DaemonConfigMapper; import com.tasteby.mapper.DaemonConfigMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map; import java.util.Map;
@@ -27,20 +29,33 @@ public class DaemonConfigService {
current.setScanEnabled(Boolean.TRUE.equals(body.get("scan_enabled"))); current.setScanEnabled(Boolean.TRUE.equals(body.get("scan_enabled")));
} }
if (body.containsKey("scan_interval_min")) { if (body.containsKey("scan_interval_min")) {
current.setScanIntervalMin(((Number) body.get("scan_interval_min")).intValue()); // #275 — 0/음수 입력으로 30초 사이클 폭주 방지. ClassCastException 대신 400.
current.setScanIntervalMin(requirePositiveInt(body.get("scan_interval_min"), "scan_interval_min"));
} }
if (body.containsKey("process_enabled")) { if (body.containsKey("process_enabled")) {
current.setProcessEnabled(Boolean.TRUE.equals(body.get("process_enabled"))); current.setProcessEnabled(Boolean.TRUE.equals(body.get("process_enabled")));
} }
if (body.containsKey("process_interval_min")) { if (body.containsKey("process_interval_min")) {
current.setProcessIntervalMin(((Number) body.get("process_interval_min")).intValue()); current.setProcessIntervalMin(requirePositiveInt(body.get("process_interval_min"), "process_interval_min"));
} }
if (body.containsKey("process_limit")) { if (body.containsKey("process_limit")) {
current.setProcessLimit(((Number) body.get("process_limit")).intValue()); current.setProcessLimit(requirePositiveInt(body.get("process_limit"), "process_limit"));
} }
mapper.updateConfig(current); mapper.updateConfig(current);
} }
/** #275 — 양의 정수 가드. 비숫자/0/음수는 400. */
private static int requirePositiveInt(Object raw, String field) {
if (!(raw instanceof Number n)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + "은(는) 정수여야 합니다");
}
int v = n.intValue();
if (v < 1) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + "은(는) 1 이상이어야 합니다 (폭주 방지)");
}
return v;
}
public void updateLastScan() { public void updateLastScan() {
mapper.updateLastScan(); mapper.updateLastScan();
} }

View File

@@ -50,8 +50,13 @@ public class DaemonScheduler {
Instant lastScan = config.getLastScanAt() != null ? config.getLastScanAt().toInstant() : null; Instant lastScan = config.getLastScanAt() != null ? config.getLastScanAt().toInstant() : null;
if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.getScanIntervalMin(), ChronoUnit.MINUTES))) { if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.getScanIntervalMin(), ChronoUnit.MINUTES))) {
log.info("Running scheduled channel scan..."); log.info("Running scheduled channel scan...");
int newVideos = youTubeService.scanAllChannels(); int newVideos = 0;
daemonConfigService.updateLastScan(); try {
newVideos = youTubeService.scanAllChannels();
} finally {
// #275 — 외부 호출 예외 시에도 last_scan_at을 갱신해 다음 cron까지의 backoff를 보장
daemonConfigService.updateLastScan();
}
if (newVideos > 0) { if (newVideos > 0) {
cacheService.flush(); cacheService.flush();
log.info("Scan completed: {} new videos", newVideos); log.info("Scan completed: {} new videos", newVideos);
@@ -63,8 +68,12 @@ public class DaemonScheduler {
Instant lastProcess = config.getLastProcessAt() != null ? config.getLastProcessAt().toInstant() : null; Instant lastProcess = config.getLastProcessAt() != null ? config.getLastProcessAt().toInstant() : null;
if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.getProcessIntervalMin(), ChronoUnit.MINUTES))) { if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.getProcessIntervalMin(), ChronoUnit.MINUTES))) {
log.info("Running scheduled video processing (limit={})...", config.getProcessLimit()); log.info("Running scheduled video processing (limit={})...", config.getProcessLimit());
int restaurants = pipelineService.processPending(config.getProcessLimit()); int restaurants = 0;
daemonConfigService.updateLastProcess(); try {
restaurants = pipelineService.processPending(config.getProcessLimit());
} finally {
daemonConfigService.updateLastProcess();
}
if (restaurants > 0) { if (restaurants > 0) {
cacheService.flush(); cacheService.flush();
log.info("Processing completed: {} restaurants extracted", restaurants); log.info("Processing completed: {} restaurants extracted", restaurants);

View File

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

View File

@@ -156,7 +156,15 @@ public class GeocodingService {
if (country.isEmpty() && !city.isEmpty()) country = "한국"; if (country.isEmpty() && !city.isEmpty()) country = "한국";
if (country.isEmpty()) return null; if (country.isEmpty()) return null;
return country + "|" + city + "|" + district; // #292 — 빈 토큰은 region 문자열에 포함시키지 않는다(`한국||구` 형식 방지).
StringBuilder sb = new StringBuilder(country);
if (!city.isEmpty()) {
sb.append('|').append(city);
if (!district.isEmpty()) sb.append('|').append(district);
} else if (!district.isEmpty()) {
// city 없이 district만 있는 경우는 정확도 낮으므로 무시
}
return sb.toString();
} }
private Map<String, Object> geocode(String query) { private Map<String, Object> geocode(String query) {

View File

@@ -3,6 +3,7 @@ package com.tasteby.service;
import com.tasteby.domain.Memo; import com.tasteby.domain.Memo;
import com.tasteby.mapper.MemoMapper; import com.tasteby.mapper.MemoMapper;
import com.tasteby.util.IdGenerator; import com.tasteby.util.IdGenerator;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -25,11 +26,18 @@ public class MemoService {
@Transactional @Transactional
public Memo upsert(String userId, String restaurantId, Double rating, String memoText, LocalDate visitedAt) { public Memo upsert(String userId, String restaurantId, Double rating, String memoText, LocalDate visitedAt) {
String visitedStr = visitedAt != null ? visitedAt.toString() : null; String visitedStr = visitedAt != null ? visitedAt.toString() : null;
// #294 — 동시성 가드: 사전 SELECT → 분기 INSERT/UPDATE 패턴은 두 트랜잭션이 동시에 미존재
// 판정 후 둘 다 INSERT → UNIQUE 충돌(500). INSERT 우선 시도 후 DuplicateKeyException 시 UPDATE.
Memo existing = mapper.findByUserAndRestaurant(userId, restaurantId); Memo existing = mapper.findByUserAndRestaurant(userId, restaurantId);
if (existing != null) { if (existing != null) {
mapper.updateMemo(userId, restaurantId, rating, memoText, visitedStr); mapper.updateMemo(userId, restaurantId, rating, memoText, visitedStr);
} else { } else {
mapper.insertMemo(IdGenerator.newId(), userId, restaurantId, rating, memoText, visitedStr); try {
mapper.insertMemo(IdGenerator.newId(), userId, restaurantId, rating, memoText, visitedStr);
} catch (DuplicateKeyException e) {
// 동시 INSERT 충돌 → UPDATE로 폴백
mapper.updateMemo(userId, restaurantId, rating, memoText, visitedStr);
}
} }
return mapper.findByUserAndRestaurant(userId, restaurantId); return mapper.findByUserAndRestaurant(userId, restaurantId);
} }

View File

@@ -87,6 +87,9 @@ public class PipelineService {
String videoDbId = (String) video.get("id"); String videoDbId = (String) video.get("id");
String title = (String) video.get("title"); String title = (String) video.get("title");
// #292 — 외부 가시성을 위해 진입 시 processing 전이 (이미 processing이면 no-op)
updateVideoStatus(videoDbId, "processing", null, null);
var result = extractorService.extractRestaurants(title, transcript, customPrompt); var result = extractorService.extractRestaurants(title, transcript, customPrompt);
if (result.restaurants().isEmpty()) { if (result.restaurants().isEmpty()) {
updateVideoStatus(videoDbId, "done", null, result.rawResponse()); updateVideoStatus(videoDbId, "done", null, result.rawResponse());
@@ -105,18 +108,26 @@ public class PipelineService {
// Build upsert data // Build upsert data
var data = new HashMap<String, Object>(); var data = new HashMap<String, Object>();
data.put("name", name); data.put("name", name);
data.put("address", geo != null ? geo.get("formatted_address") : restData.get("address"));
data.put("region", restData.get("region")); data.put("region", restData.get("region"));
data.put("latitude", geo != null ? geo.get("latitude") : null);
data.put("longitude", geo != null ? geo.get("longitude") : null);
data.put("cuisine_type", restData.get("cuisine_type")); data.put("cuisine_type", restData.get("cuisine_type"));
data.put("price_range", restData.get("price_range")); data.put("price_range", restData.get("price_range"));
data.put("google_place_id", geo != null ? geo.get("google_place_id") : null); // #292 — geocode 실패(geo==null) 시 좌표/주소/place_id 등 기존 값 보존하기 위해
data.put("phone", geo != null ? geo.get("phone") : null); // null을 명시적으로 put하지 않는다. upsert 측에서 누락 컬럼은 그대로 유지.
data.put("website", geo != null ? geo.get("website") : null); if (geo != null) {
data.put("business_status", geo != null ? geo.get("business_status") : null); data.put("address", geo.get("formatted_address"));
data.put("rating", geo != null ? geo.get("rating") : null); data.put("latitude", geo.get("latitude"));
data.put("rating_count", geo != null ? geo.get("rating_count") : null); data.put("longitude", geo.get("longitude"));
data.put("google_place_id", geo.get("google_place_id"));
data.put("phone", geo.get("phone"));
data.put("website", geo.get("website"));
data.put("business_status", geo.get("business_status"));
data.put("rating", geo.get("rating"));
data.put("rating_count", geo.get("rating_count"));
} else {
// geocode 실패한 첫 등록 케이스에서 최소한의 주소(LLM이 추출한 원시값)는 저장
Object rawAddr = restData.get("address");
if (rawAddr != null) data.put("address", rawAddr);
}
String restId = restaurantService.upsert(data); String restId = restaurantService.upsert(data);

View File

@@ -5,6 +5,7 @@ import com.tasteby.domain.Review;
import com.tasteby.mapper.ReviewMapper; import com.tasteby.mapper.ReviewMapper;
import com.tasteby.util.IdGenerator; import com.tasteby.util.IdGenerator;
import com.tasteby.util.JsonUtil; import com.tasteby.util.JsonUtil;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -60,10 +61,15 @@ public class ReviewService {
if (existingId != null) { if (existingId != null) {
mapper.deleteFavorite(userId, restaurantId); mapper.deleteFavorite(userId, restaurantId);
return false; return false;
} else {
mapper.insertFavorite(IdGenerator.newId(), userId, restaurantId);
return true;
} }
// #294 — 동시성 가드: 동시 INSERT 시 UNIQUE 충돌 → 한 쪽 500.
// INSERT 시도 후 DuplicateKeyException은 "이미 추가됨"으로 간주 (토글 의도는 ON).
try {
mapper.insertFavorite(IdGenerator.newId(), userId, restaurantId);
} catch (DuplicateKeyException ignored) {
// 다른 트랜잭션이 먼저 INSERT 함 — 결과는 어쨌든 즐겨찾기 ON.
}
return true;
} }
public List<Restaurant> getUserFavorites(String userId) { public List<Restaurant> getUserFavorites(String userId) {

View File

@@ -1,9 +1,12 @@
package com.tasteby.service; package com.tasteby.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tasteby.domain.Restaurant; import com.tasteby.domain.Restaurant;
import com.tasteby.mapper.SearchMapper; import com.tasteby.mapper.SearchMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.*; import java.util.*;
@@ -12,12 +15,17 @@ import java.util.*;
public class SearchService { public class SearchService {
private static final Logger log = LoggerFactory.getLogger(SearchService.class); private static final Logger log = LoggerFactory.getLogger(SearchService.class);
private static final ObjectMapper JSON = new ObjectMapper();
private static final TypeReference<List<Restaurant>> LIST_TYPE = new TypeReference<>() {};
private final SearchMapper searchMapper; private final SearchMapper searchMapper;
private final RestaurantService restaurantService; private final RestaurantService restaurantService;
private final VectorService vectorService; private final VectorService vectorService;
private final CacheService cache; private final CacheService cache;
@Value("${app.search.max-distance:0.57}")
private double maxDistance;
public SearchService(SearchMapper searchMapper, public SearchService(SearchMapper searchMapper,
RestaurantService restaurantService, RestaurantService restaurantService,
VectorService vectorService, VectorService vectorService,
@@ -33,8 +41,8 @@ public class SearchService {
String cached = cache.getRaw(key); String cached = cache.getRaw(key);
if (cached != null) { if (cached != null) {
try { try {
var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); // #293 — ObjectMapper 재사용 (필드 static)
return mapper.readValue(cached, new com.fasterxml.jackson.core.type.TypeReference<List<Restaurant>>() {}); return JSON.readValue(cached, LIST_TYPE);
} catch (Exception ignored) {} } catch (Exception ignored) {}
} }
@@ -44,13 +52,20 @@ public class SearchService {
case "hybrid" -> { case "hybrid" -> {
var kw = keywordSearch(q, limit); var kw = keywordSearch(q, limit);
var sem = semanticSearch(q, limit); var sem = semanticSearch(q, limit);
// #293 — semantic 결과에도 channels 부착 (이전: keyword에만 부착되어 hybrid에서 sem 결과는 채널 누락)
if (!sem.isEmpty()) attachChannels(sem);
Set<String> seen = new HashSet<>(); Set<String> seen = new HashSet<>();
var merged = new ArrayList<Restaurant>(); var merged = new ArrayList<Restaurant>();
for (var r : kw) { if (seen.add(r.getId())) merged.add(r); } for (var r : kw) { if (seen.add(r.getId())) merged.add(r); }
for (var r : sem) { if (seen.add(r.getId())) merged.add(r); } for (var r : sem) { if (seen.add(r.getId())) merged.add(r); }
result = merged.size() > limit ? merged.subList(0, limit) : merged; result = merged.size() > limit ? merged.subList(0, limit) : merged;
} }
default -> result = keywordSearch(q, limit); case "keyword" -> result = keywordSearch(q, limit);
default -> {
// #293 — 알 수 없는 mode는 silent fallback 대신 경고 로그
log.warn("Unknown search mode '{}', falling back to keyword", mode);
result = keywordSearch(q, limit);
}
} }
cache.set(key, result); cache.set(key, result);
@@ -58,7 +73,10 @@ public class SearchService {
} }
private List<Restaurant> keywordSearch(String q, int limit) { private List<Restaurant> keywordSearch(String q, int limit) {
String pattern = "%" + q + "%"; // #293 — LIKE 와일드카드 escape: 사용자 입력의 %, _, \ 를 리터럴로 처리.
// SQL에서는 ESCAPE '\\' 절을 사용 (SearchMapper.xml).
String escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
String pattern = "%" + escaped + "%";
List<Restaurant> results = searchMapper.keywordSearch(pattern, limit); List<Restaurant> results = searchMapper.keywordSearch(pattern, limit);
if (!results.isEmpty()) { if (!results.isEmpty()) {
attachChannels(results); attachChannels(results);
@@ -68,7 +86,7 @@ public class SearchService {
private List<Restaurant> semanticSearch(String q, int limit) { private List<Restaurant> semanticSearch(String q, int limit) {
try { try {
var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), 0.57); var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), maxDistance);
if (similar.isEmpty()) return List.of(); if (similar.isEmpty()) return List.of();
Set<String> seen = new LinkedHashSet<>(); Set<String> seen = new LinkedHashSet<>();

View File

@@ -2,11 +2,16 @@ package com.tasteby.service;
import com.tasteby.domain.SiteVisitStats; import com.tasteby.domain.SiteVisitStats;
import com.tasteby.mapper.StatsMapper; import com.tasteby.mapper.StatsMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
public class StatsService { public class StatsService {
private static final Logger log = LoggerFactory.getLogger(StatsService.class);
private final StatsMapper mapper; private final StatsMapper mapper;
public StatsService(StatsMapper mapper) { public StatsService(StatsMapper mapper) {
@@ -14,7 +19,19 @@ public class StatsService {
} }
public void recordVisit() { public void recordVisit() {
mapper.recordVisit(); // #274 — 자정 경계 동시성: 두 트랜잭션이 동시에 'NOT MATCHED' 판정 → 둘 다 INSERT
// → PK/UNIQUE 충돌 시 한 쪽 500. 1회 재시도(다음엔 MATCHED → UPDATE 분기).
try {
mapper.recordVisit();
} catch (DataIntegrityViolationException e) {
log.debug("recordVisit conflict (midnight race), retry once: {}", e.getMessage());
try {
mapper.recordVisit();
} catch (DataIntegrityViolationException retryFail) {
// 두 번째 시도도 실패: 카운트 1건 손실은 수용 (운영 영향 미미)
log.warn("recordVisit double-conflict, dropping one count: {}", retryFail.getMessage());
}
}
} }
public SiteVisitStats getVisits() { public SiteVisitStats getVisits() {

View File

@@ -27,12 +27,15 @@ public class VectorService {
*/ */
public List<Map<String, Object>> searchSimilar(String query, int topK, double maxDistance) { public List<Map<String, Object>> searchSimilar(String query, int topK, double maxDistance) {
List<List<Double>> embeddings = genAi.embedTexts(List.of(query)); List<List<Double>> embeddings = genAi.embedTexts(List.of(query));
if (embeddings.isEmpty()) return List.of(); // #293 — embeddings 빈/null 가드 (NPE/IndexOutOfBoundsException 방지)
if (embeddings == null || embeddings.isEmpty()) return List.of();
List<Double> first = embeddings.getFirst();
if (first == null || first.isEmpty()) return List.of();
// Convert to float array for Oracle VECTOR type // Convert to float array for Oracle VECTOR type
float[] queryVec = new float[embeddings.getFirst().size()]; float[] queryVec = new float[first.size()];
for (int i = 0; i < queryVec.length; i++) { for (int i = 0; i < queryVec.length; i++) {
queryVec[i] = embeddings.getFirst().get(i).floatValue(); queryVec[i] = first.get(i).floatValue();
} }
String sql = """ String sql = """

View File

@@ -28,6 +28,9 @@ public class VideoService {
VideoDetail detail = mapper.findDetail(id); VideoDetail detail = mapper.findDetail(id);
if (detail == null) return null; if (detail == null) return null;
List<VideoRestaurantLink> restaurants = mapper.findVideoRestaurants(id); List<VideoRestaurantLink> restaurants = mapper.findVideoRestaurants(id);
if (restaurants != null) {
restaurants.forEach(r -> r.setEvaluation(JsonUtil.normalizeEvaluation(r.getEvaluation())));
}
detail.setRestaurants(restaurants != null ? restaurants : List.of()); detail.setRestaurants(restaurants != null ? restaurants : List.of());
return detail; return detail;
} }
@@ -59,6 +62,7 @@ public class VideoService {
mapper.cleanupOrphanRestaurant(restaurantId); mapper.cleanupOrphanRestaurant(restaurantId);
} }
@Transactional
public int saveVideosBatch(String channelId, List<Map<String, Object>> videos) { public int saveVideosBatch(String channelId, List<Map<String, Object>> videos) {
Set<String> existing = new HashSet<>(mapper.getExistingVideoIds(channelId)); Set<String> existing = new HashSet<>(mapper.getExistingVideoIds(channelId));
int saved = 0; int saved = 0;

View File

@@ -59,6 +59,11 @@ app:
cache: cache:
ttl-seconds: 600 ttl-seconds: 600
search:
# #293 — 벡터 검색 cosine distance 임계값 (0.0=완전일치, 1.0=직교).
# 0.57은 cohere embed-v4 한국어 시맨틱 적합도 기준 경험값.
max-distance: ${SEARCH_MAX_DISTANCE:0.57}
daemon: daemon:
# 인스턴스 차원 스케줄러 활성화. dev/prod가 같은 DB를 공유하므로 # 인스턴스 차원 스케줄러 활성화. dev/prod가 같은 DB를 공유하므로
# dev .env에 DAEMON_ENABLED=false를 설정해 dev 폴링을 끄고 prod만 동작시킨다. # dev .env에 DAEMON_ENABLED=false를 설정해 dev 폴링을 끄고 prod만 동작시킨다.

View File

@@ -44,7 +44,8 @@
</update> </update>
<select id="findByChannelId" resultMap="channelResultMap"> <select id="findByChannelId" resultMap="channelResultMap">
SELECT id, channel_id, channel_name, title_filter <!-- #295 — findAllActive와 동일하게 description/tags/sort_order까지 SELECT -->
SELECT id, channel_id, channel_name, title_filter, description, tags, sort_order
FROM channels FROM channels
WHERE channel_id = #{channelId} AND is_active = 1 WHERE channel_id = #{channelId} AND is_active = 1
</select> </select>

View File

@@ -79,7 +79,8 @@
</select> </select>
<select id="getAvgRating" resultType="map"> <select id="getAvgRating" resultType="map">
SELECT ROUND(AVG(rating), 1) AS avg_rating, COUNT(*) AS review_count <!-- #294 — review 0건이면 AVG는 NULL → 클라이언트 NaN 처리 부담. NVL로 0.0 보장. -->
SELECT NVL(ROUND(AVG(rating), 1), 0) AS avg_rating, COUNT(*) AS review_count
FROM user_reviews FROM user_reviews
WHERE restaurant_id = #{restaurantId} WHERE restaurant_id = #{restaurantId}
</select> </select>

View File

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

View File

@@ -1,6 +1,6 @@
# 설계서: LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김 (#322) # 설계서: LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김 (#322)
> **상태**: Draft <!-- Draft | Approved | Superseded --> > **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [AI] Architect · **최종수정**: 2026-06-15 > **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #322 · 관련 ADR: 없음 > **추적성** — Redmine: #322 · 관련 ADR: 없음
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/RestaurantVerifyService.java`(신규), `backend-java/src/main/java/com/tasteby/domain/Restaurant.java`(필드 3개 추가), `backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml`(컬럼 매핑), `backend-java/src/main/java/com/tasteby/service/RestaurantService.java`(필터링), DB 마이그레이션 SQL > · 구현 파일: `backend-java/src/main/java/com/tasteby/service/RestaurantVerifyService.java`(신규), `backend-java/src/main/java/com/tasteby/domain/Restaurant.java`(필드 3개 추가), `backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml`(컬럼 매핑), `backend-java/src/main/java/com/tasteby/service/RestaurantService.java`(필터링), DB 마이그레이션 SQL

View File

@@ -1470,12 +1470,12 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
setEditRest({ setEditRest({
name: r.name, name: r.name,
cuisine_type: r.cuisine_type || "", cuisine_type: r.cuisine_type || "",
foods_mentioned: r.foods_mentioned.join(", "), foods_mentioned: (r.foods_mentioned || []).join(", "),
evaluation: evalText, evaluation: evalText,
address: r.address || "", address: r.address || "",
region: r.region || "", region: r.region || "",
price_range: r.price_range || "", price_range: r.price_range || "",
guests: r.guests.join(", "), guests: (r.guests || []).join(", "),
}); });
} : undefined} } : undefined}
title={isAdmin ? "클릭하여 수정" : undefined} title={isAdmin ? "클릭하여 수정" : undefined}
@@ -1513,7 +1513,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
{r.cuisine_type && <p>: {r.cuisine_type}</p>} {r.cuisine_type && <p>: {r.cuisine_type}</p>}
{r.price_range && <p>: {r.price_range}</p>} {r.price_range && <p>: {r.price_range}</p>}
</div> </div>
{r.foods_mentioned.length > 0 && ( {r.foods_mentioned?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2"> <div className="flex flex-wrap gap-1 mt-2">
{r.foods_mentioned.map((f, j) => ( {r.foods_mentioned.map((f, j) => (
<span key={j} className="px-1.5 py-0.5 bg-brand-50 text-brand-700 rounded text-xs">{f}</span> <span key={j} className="px-1.5 py-0.5 bg-brand-50 text-brand-700 rounded text-xs">{f}</span>
@@ -1523,7 +1523,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
{r.evaluation?.text && ( {r.evaluation?.text && (
<p className="mt-1 text-xs text-gray-600">{r.evaluation.text}</p> <p className="mt-1 text-xs text-gray-600">{r.evaluation.text}</p>
)} )}
{r.guests.length > 0 && ( {r.guests?.length > 0 && (
<p className="mt-1 text-xs text-gray-500">: {r.guests.join(", ")}</p> <p className="mt-1 text-xs text-gray-500">: {r.guests.join(", ")}</p>
)} )}
</div> </div>
@@ -1627,6 +1627,45 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
// #322/#323 LLM 검증 UI
const [verifyPending, setVerifyPending] = useState<number | null>(null);
const [verifying, setVerifying] = useState(false);
const [verifyResult, setVerifyResult] = useState<string | null>(null);
const loadVerifyPending = useCallback(() => {
api.getVerifyPending().then((r) => setVerifyPending(r.pending)).catch(() => setVerifyPending(null));
}, []);
useEffect(() => { loadVerifyPending(); }, [loadVerifyPending]);
const handleVerifyAll = async () => {
if (!isAdmin) return;
if (!confirm(`미검증 식당 ${verifyPending ?? "?"}건을 LLM으로 일괄 검증합니다.\n잘못 등록된 데이터/프랜차이즈를 자동으로 숨김 처리합니다.\n진행할까요?`)) return;
setVerifying(true);
setVerifyResult(null);
try {
const r = await api.verifyAll(10);
setVerifyResult(`${r.processed}건 검증 완료`);
loadVerifyPending();
load();
} catch (e) {
setVerifyResult(`실패: ${e instanceof Error ? e.message : String(e)}`);
} finally {
setVerifying(false);
}
};
const handleToggleHidden = async (r: Restaurant) => {
if (!isAdmin) return;
const becomingHidden = !r.hidden;
const reason = becomingHidden ? (prompt("숨김 사유(선택)", "manual") ?? "manual") : "";
try {
await api.setRestaurantHidden(r.id, becomingHidden, reason || "manual");
load();
} catch (e) {
alert(`실패: ${e instanceof Error ? e.message : String(e)}`);
}
};
const filtered = restaurants.filter((r) => { const filtered = restaurants.filter((r) => {
if (nameSearch && !r.name.toLowerCase().includes(nameSearch.toLowerCase())) return false; if (nameSearch && !r.name.toLowerCase().includes(nameSearch.toLowerCase())) return false;
return true; return true;
@@ -1728,6 +1767,18 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
)} )}
</div> </div>
{isAdmin && (<> {isAdmin && (<>
{/* #322/#323 — LLM 검증 */}
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<span> {verifyPending ?? "?"}</span>
<button
onClick={handleVerifyAll}
disabled={verifying || verifyPending === 0}
className="px-3 py-1 text-xs rounded bg-amber-500 hover:bg-amber-600 text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
{verifying ? "검증 중..." : "LLM 검증"}
</button>
{verifyResult && <span className="text-amber-600">{verifyResult}</span>}
</div>
<button <button
onClick={async () => { onClick={async () => {
const pending = await fetch(`/api/restaurants/tabling-pending`, { const pending = await fetch(`/api/restaurants/tabling-pending`, {
@@ -1890,6 +1941,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("price_range")}>{sortIcon("price_range")}</th> <th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("price_range")}>{sortIcon("price_range")}</th>
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("rating")}>{sortIcon("rating")}</th> <th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("rating")}>{sortIcon("rating")}</th>
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("business_status")}>{sortIcon("business_status")}</th> <th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("business_status")}>{sortIcon("business_status")}</th>
<th className="text-center px-4 py-3"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -1917,11 +1969,34 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
<span className="text-xs text-gray-400">-</span> <span className="text-xs text-gray-400">-</span>
)} )}
</td> </td>
<td className="px-4 py-3 text-center" onClick={(e) => e.stopPropagation()}>
{r.hidden ? (
<button
onClick={() => handleToggleHidden(r)}
disabled={!isAdmin}
title={r.hidden_reason || "manual"}
className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold hover:bg-red-200 disabled:opacity-50"
>
{r.hidden_reason ? `(${r.hidden_reason.slice(0, 12)})` : ""}
</button>
) : r.verified_at ? (
<button
onClick={() => handleToggleHidden(r)}
disabled={!isAdmin}
title="검증 통과 — 클릭하면 숨김"
className="px-1.5 py-0.5 bg-green-100 text-green-700 rounded text-[10px] font-semibold hover:bg-green-200 disabled:opacity-50"
>
OK
</button>
) : (
<span className="text-xs text-gray-400"></span>
)}
</td>
</tr> </tr>
))} ))}
{!loading && filtered.length === 0 && ( {!loading && filtered.length === 0 && (
<tr> <tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-400"> <td colSpan={7} className="px-4 py-8 text-center text-gray-400">
</td> </td>
</tr> </tr>
@@ -2144,6 +2219,7 @@ interface AdminUser {
email: string | null; email: string | null;
nickname: string | null; nickname: string | null;
avatar_url: string | null; avatar_url: string | null;
is_admin: boolean;
provider: string | null; provider: string | null;
created_at: string | null; created_at: string | null;
favorite_count: number; favorite_count: number;
@@ -2246,6 +2322,7 @@ function UsersPanel() {
<tr> <tr>
<th className="text-left px-4 py-2"></th> <th className="text-left px-4 py-2"></th>
<th className="text-left px-4 py-2"></th> <th className="text-left px-4 py-2"></th>
<th className="text-center px-4 py-2"></th>
<th className="text-center px-4 py-2"></th> <th className="text-center px-4 py-2"></th>
<th className="text-center px-4 py-2"></th> <th className="text-center px-4 py-2"></th>
<th className="text-center px-4 py-2"></th> <th className="text-center px-4 py-2"></th>
@@ -2282,6 +2359,27 @@ function UsersPanel() {
</div> </div>
</td> </td>
<td className="px-4 py-2 text-gray-500">{u.email || "-"}</td> <td className="px-4 py-2 text-gray-500">{u.email || "-"}</td>
<td className="px-4 py-2 text-center">
<button
onClick={async (e) => {
e.stopPropagation();
try {
await api.updateAdminUserAdmin(u.id, !u.is_admin);
setUsers((prev) => prev.map((x) => x.id === u.id ? { ...x, is_admin: !u.is_admin } : x));
} catch (err) {
console.error("Failed to update admin:", err);
alert("관리자 권한 변경에 실패했습니다.");
}
}}
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium transition-colors ${
u.is_admin
? "bg-green-100 text-green-700 hover:bg-green-200"
: "bg-gray-100 text-gray-400 hover:bg-gray-200"
}`}
>
{u.is_admin ? "ON" : "OFF"}
</button>
</td>
<td className="px-4 py-2 text-center"> <td className="px-4 py-2 text-center">
{u.favorite_count > 0 ? ( {u.favorite_count > 0 ? (
<span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 rounded-full text-xs font-medium"> <span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 rounded-full text-xs font-medium">

View File

@@ -0,0 +1,52 @@
// #304 어드민 페이지 공통 유틸.
// 결함: localStorage 직접 접근 10+곳 / SSE 파싱 코드 6곳 중복.
const TOKEN_KEY = "tasteby_token";
export function getAdminToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(TOKEN_KEY);
}
export function authHeaders(): Record<string, string> {
const token = getAdminToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
/**
* SSE(Server-Sent Events) 스트림을 라인 단위로 파싱하여 onEvent 콜백을 호출.
* 호환 패턴: `data: { ...json... }` 한 줄 = 한 이벤트.
* 비어있는 줄은 무시. JSON 파싱 실패 시 콜백 skip.
*/
export async function consumeSseStream(
response: Response,
onEvent: (event: unknown) => void,
onError?: (err: unknown) => void,
): Promise<void> {
const reader = response.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (!payload) continue;
try {
onEvent(JSON.parse(payload));
} catch {
// 무시: 일부 SSE 줄이 JSON이 아닐 수도 있음
}
}
}
} catch (err) {
onError?.(err);
}
}

View File

@@ -51,6 +51,10 @@ export interface Restaurant {
website: string | null; website: string | null;
channels?: string[]; channels?: string[];
foods_mentioned?: string[]; foods_mentioned?: string[];
// #322 LLM 검증
hidden?: boolean;
hidden_reason?: string | null;
verified_at?: string | null;
} }
export interface VideoLink { export interface VideoLink {
@@ -310,6 +314,7 @@ export const api = {
email: string | null; email: string | null;
nickname: string | null; nickname: string | null;
avatar_url: string | null; avatar_url: string | null;
is_admin: boolean;
provider: string | null; provider: string | null;
created_at: string | null; created_at: string | null;
favorite_count: number; favorite_count: number;
@@ -320,6 +325,14 @@ export const api = {
}>(`/api/admin/users${qs ? `?${qs}` : ""}`); }>(`/api/admin/users${qs ? `?${qs}` : ""}`);
}, },
updateAdminUserAdmin(userId: string, admin: boolean) {
return fetchApi<{ success: boolean }>(`/api/admin/users/${userId}/admin`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ admin }),
});
},
getAdminUserFavorites(userId: string) { getAdminUserFavorites(userId: string) {
return fetchApi< return fetchApi<
{ {
@@ -567,4 +580,30 @@ export const api = {
{ method: "POST" } { method: "POST" }
); );
}, },
// #322 — LLM 검증 어드민 API
getVerifyPending() {
return fetchApi<{ pending: number }>("/api/admin/restaurants/verify/pending");
},
verifyAll(batchSize: number = 10) {
return fetchApi<{ processed: number }>(
`/api/admin/restaurants/verify/all?batchSize=${batchSize}`,
{ method: "POST" }
);
},
verifyOne(id: string) {
return fetchApi<{ success: boolean; id: string }>(
`/api/admin/restaurants/${id}/verify`,
{ method: "POST" }
);
},
setRestaurantHidden(id: string, hidden: boolean, reason: string = "manual") {
return fetchApi<{ success: boolean; id: string; hidden: boolean }>(
`/api/admin/restaurants/${id}/hidden`,
{
method: "PATCH",
body: JSON.stringify({ hidden, reason }),
}
);
},
}; };