Compare commits

..

7 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
24 changed files with 432 additions and 56 deletions

View File

@@ -6,6 +6,34 @@
## 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/사용자 가시성)

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

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

@@ -7,6 +7,7 @@ import com.tasteby.security.AuthUtil;
import com.tasteby.service.CacheService;
import com.tasteby.service.GeocodingService;
import com.tasteby.service.RestaurantService;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
@@ -47,6 +48,12 @@ public class RestaurantController {
this.objectMapper = objectMapper;
}
// #290 — Bean 종료 시 virtual thread executor를 정리하여 리소스 누수 방지.
@PreDestroy
public void shutdownExecutor() {
executor.shutdown();
}
@GetMapping
public List<Restaurant> list(
@RequestParam(defaultValue = "100") int limit,
@@ -61,7 +68,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);
@@ -75,7 +82,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");
@@ -241,6 +248,10 @@ 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 차단. 빈 문자열은 매핑 해제로 허용.
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 : ""));
cache.flush();
return Map.of("ok", true);
@@ -367,6 +378,12 @@ 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);
@@ -379,7 +396,7 @@ public class RestaurantController {
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");

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

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

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

View File

@@ -27,8 +27,15 @@ public class CacheService {
this.redis = redis;
this.mapper = mapper;
this.ttl = Duration.ofSeconds(ttlSeconds);
try {
redis.getConnectionFactory().getConnection().ping();
// #276 — ping 연결 자원 누수 방지: try-with-resources
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");
} catch (Exception e) {
log.warn("Redis unavailable ({}), caching disabled", e.getMessage());
@@ -37,6 +44,13 @@ public class CacheService {
}
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);
}
@@ -85,4 +99,14 @@ public class CacheService {
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) {
// 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;
}

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

@@ -50,8 +50,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);
@@ -63,8 +68,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

@@ -3,6 +3,7 @@ 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;
@@ -25,11 +26,18 @@ public class MemoService {
@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 {
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);
}

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;
@@ -60,10 +61,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

@@ -27,12 +27,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 = """

View File

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

View File

@@ -44,7 +44,8 @@
</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

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

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

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;
channels?: string[];
foods_mentioned?: string[];
// #322 LLM 검증
hidden?: boolean;
hidden_reason?: string | null;
verified_at?: string | null;
}
export interface VideoLink {
@@ -310,6 +314,7 @@ export const api = {
email: string | null;
nickname: string | null;
avatar_url: string | null;
is_admin: boolean;
provider: string | null;
created_at: string | null;
favorite_count: number;
@@ -320,6 +325,14 @@ export const api = {
}>(`/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) {
return fetchApi<
{
@@ -567,4 +580,30 @@ export const api = {
{ 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 }),
}
);
},
};