Compare commits
11 Commits
v0.1.17
...
ea8db4bef3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea8db4bef3 | ||
|
|
ed076411ed | ||
|
|
865cd86aff | ||
|
|
c6428e5d5f | ||
|
|
5579c5b00f | ||
|
|
4b02293046 | ||
|
|
eb1eaa91a6 | ||
|
|
9c2dc9f43a | ||
|
|
7779d5ddfd | ||
|
|
6ea82a5561 | ||
|
|
04c54d1b1a |
52
CHANGELOG.md
52
CHANGELOG.md
@@ -6,6 +6,58 @@
|
|||||||
|
|
||||||
## 2026-06-15
|
## 2026-06-15
|
||||||
|
|
||||||
|
### 🔐 P4-3 인증 메시지 + 지도 접근성 (v0.1.22)
|
||||||
|
- #266: Google verifier 실패 메시지 고정 + log.warn (정보 누출 차단)
|
||||||
|
- #278: boundsTimerRef cleanup, '내 위치' 44px + aria-label, dead code 제거
|
||||||
|
- #277: 결함 모두 후속(#338) — deep health/version/테스트는 별도
|
||||||
|
- 후속 분리: #338(deep health), #339(브랜드 토큰화/마커 ARIA), #340(다중 audience)
|
||||||
|
- Refs: #266 #277 #278 (close)
|
||||||
|
|
||||||
|
### ⚙️ P4-2 데몬/캐시/통계 결함 (v0.1.21)
|
||||||
|
- #275: updateConfig 가드(1+ 정수), Scheduler try-finally updateLastX, GET config admin-only
|
||||||
|
- #276: ping try-with-resources + ConnectionFactory null 가드, makeKey null 가드
|
||||||
|
- #274: SiteVisitStats int → long, recordVisit DataIntegrityViolationException 1회 재시도
|
||||||
|
- 후속 분리: #335 (분산락), #336 (SCAN/자동복구), #337 (봇/레이트리밋)
|
||||||
|
- Refs: #275 #276 #274 (close)
|
||||||
|
|
||||||
|
### 🧱 P4-1 백엔드 CRUD 결함 (v0.1.20)
|
||||||
|
- #294: MemoService/ReviewService 동시성 DuplicateKeyException 가드, rating 0~5 검증, getAvgRating NVL
|
||||||
|
- #295: 유니크 충돌 typed exception, channel_id "UC..." 형식 명시 분기, findByChannelId 컬럼 보완, body null 가드
|
||||||
|
- #290: @PreDestroy executor shutdown, 캐시 silent → log.warn + cache.del, tabling/catchtable URL 스킴 화이트리스트
|
||||||
|
- 후속 분리: #332(#290), #333(#295), #334(#294) — DTO/DDG/세분화/테스트
|
||||||
|
- Refs: #290 #294 #295 (close)
|
||||||
|
|
||||||
|
### 🔍 #293 검색/벡터 결함 7건 (v0.1.19)
|
||||||
|
- SearchController: q 빈값 400 가드 (`%%` 응답 폭발 차단)
|
||||||
|
- SearchService: LIKE 와일드카드 escape (%, _, \), hybrid 모드에서 sem 결과에도 채널 부착
|
||||||
|
- SearchService: ObjectMapper/TypeReference static 재사용, 알 수 없는 mode warn 로그
|
||||||
|
- SearchService: maxDistance를 @Value("${app.search.max-distance:0.57}") 외부화 (env SEARCH_MAX_DISTANCE)
|
||||||
|
- SearchMapper.xml: LIKE 절에 ESCAPE '\' 추가
|
||||||
|
- VectorService: embeddings null/empty 가드 (NPE 차단)
|
||||||
|
- 후속 분리: #331 (batch insert + 테스트)
|
||||||
|
- Refs: #293 (close)
|
||||||
|
|
||||||
|
### 🛠 #304+#323 어드민 LLM 검증 UI + 공통 유틸 (v0.1.18)
|
||||||
|
- 신규 frontend/src/lib/admin-utils.ts:
|
||||||
|
- getAdminToken / authHeaders / consumeSseStream
|
||||||
|
- api.ts: Restaurant 타입에 hidden/hidden_reason/verified_at + verify/setRestaurantHidden API 4개
|
||||||
|
- RestaurantsPanel:
|
||||||
|
- 헤더: "미검증 N건 + LLM 검증" 버튼
|
||||||
|
- 테이블: 검증 컬럼 (숨김/OK/미검증 배지 + 클릭으로 토글)
|
||||||
|
- colSpan 7로 수정
|
||||||
|
- 후속 분리: #329 (admin 전체 파일 분리 + localStorage/SSE 11+곳 통일)
|
||||||
|
- Refs: #304 #323 #322 (close)
|
||||||
|
|
||||||
|
### 🔧 #291+#292 백엔드 결함 일괄 수정 (v0.1.17)
|
||||||
|
- ExtractorService: transcript null/blank 가드 (NPE 방지)
|
||||||
|
- PipelineService.processExtract: 진입 시 status='processing' 명시 전이 (SSE/사용자 가시성)
|
||||||
|
- PipelineService: geocode 실패 시 좌표/place_id/주소 컬럼을 data에 put하지 않아 upsert COALESCE 보존 의도 명확화
|
||||||
|
- GeocodingService.parseRegionFromAddress: 빈 토큰을 region 문자열에서 제거 ('한국||구' 깨짐 방지)
|
||||||
|
- VideoService.saveVideosBatch: @Transactional 추가 → batch insert 원자성
|
||||||
|
- .gitignore: backend-java/cookies.txt 및 **/cookies.txt
|
||||||
|
- 후속 분리: #325 (#291 잔여 MINOR), #326 (parseJson 최적화 + #292 MINOR)
|
||||||
|
- Refs: #291 #292 (close)
|
||||||
|
|
||||||
### 🧹 #322 LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김 (v0.1.16)
|
### 🧹 #322 LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김 (v0.1.16)
|
||||||
- DB 마이그레이션: restaurants에 hidden(NUMBER(1)), hidden_reason(VARCHAR2(120)), verified_at(TIMESTAMP) + idx_restaurants_hidden
|
- DB 마이그레이션: restaurants에 hidden(NUMBER(1)), hidden_reason(VARCHAR2(120)), verified_at(TIMESTAMP) + idx_restaurants_hidden
|
||||||
- 도메인/Mapper/Service 확장: includeHidden 옵션, updateVerification, findUnverified 등
|
- 도메인/Mapper/Service 확장: includeHidden 옵션, updateVerification, findUnverified 등
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public class DaemonController {
|
|||||||
|
|
||||||
@GetMapping("/config")
|
@GetMapping("/config")
|
||||||
public DaemonConfig getConfig() {
|
public DaemonConfig getConfig() {
|
||||||
|
// #275 — 데몬 운영 설정은 admin 전용 (이전: 공개 노출 — 정보 누출 위험)
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
DaemonConfig config = daemonConfigService.getConfig();
|
DaemonConfig config = daemonConfigService.getConfig();
|
||||||
return config != null ? config : DaemonConfig.builder().build();
|
return config != null ? config : DaemonConfig.builder().build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.tasteby.security.AuthUtil;
|
|||||||
import com.tasteby.service.CacheService;
|
import com.tasteby.service.CacheService;
|
||||||
import com.tasteby.service.GeocodingService;
|
import com.tasteby.service.GeocodingService;
|
||||||
import com.tasteby.service.RestaurantService;
|
import com.tasteby.service.RestaurantService;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -47,6 +48,12 @@ public class RestaurantController {
|
|||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #290 — Bean 종료 시 virtual thread executor를 정리하여 리소스 누수 방지.
|
||||||
|
@PreDestroy
|
||||||
|
public void shutdownExecutor() {
|
||||||
|
executor.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<Restaurant> list(
|
public List<Restaurant> list(
|
||||||
@RequestParam(defaultValue = "100") int limit,
|
@RequestParam(defaultValue = "100") int limit,
|
||||||
@@ -61,7 +68,7 @@ public class RestaurantController {
|
|||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
try {
|
try {
|
||||||
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
|
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
|
||||||
}
|
}
|
||||||
var result = restaurantService.findAll(limit, offset, cuisine, region, channel);
|
var result = restaurantService.findAll(limit, offset, cuisine, region, channel);
|
||||||
cache.set(key, result);
|
cache.set(key, result);
|
||||||
@@ -75,7 +82,7 @@ public class RestaurantController {
|
|||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
try {
|
try {
|
||||||
return objectMapper.readValue(cached, Restaurant.class);
|
return objectMapper.readValue(cached, Restaurant.class);
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
|
||||||
}
|
}
|
||||||
var r = restaurantService.findById(id);
|
var r = restaurantService.findById(id);
|
||||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||||
@@ -241,6 +248,10 @@ public class RestaurantController {
|
|||||||
var r = restaurantService.findById(id);
|
var r = restaurantService.findById(id);
|
||||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||||
String url = body.get("tabling_url");
|
String url = body.get("tabling_url");
|
||||||
|
// #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
|
||||||
|
if (url != null && !url.isBlank() && !url.startsWith("https://tabling.co.kr/")) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "테이블링 URL은 https://tabling.co.kr/ 만 허용");
|
||||||
|
}
|
||||||
restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
|
restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
|
||||||
cache.flush();
|
cache.flush();
|
||||||
return Map.of("ok", true);
|
return Map.of("ok", true);
|
||||||
@@ -367,6 +378,12 @@ public class RestaurantController {
|
|||||||
var r = restaurantService.findById(id);
|
var r = restaurantService.findById(id);
|
||||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||||
String url = body.get("catchtable_url");
|
String url = body.get("catchtable_url");
|
||||||
|
// #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
|
||||||
|
if (url != null && !url.isBlank()
|
||||||
|
&& !url.startsWith("https://app.catchtable.co.kr/")
|
||||||
|
&& !url.startsWith("https://www.catchtable.co.kr/")) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "캐치테이블 URL은 https://(app|www).catchtable.co.kr/ 만 허용");
|
||||||
|
}
|
||||||
restaurantService.update(id, Map.of("catchtable_url", url != null ? url : ""));
|
restaurantService.update(id, Map.of("catchtable_url", url != null ? url : ""));
|
||||||
cache.flush();
|
cache.flush();
|
||||||
return Map.of("ok", true);
|
return Map.of("ok", true);
|
||||||
@@ -379,7 +396,7 @@ public class RestaurantController {
|
|||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
try {
|
try {
|
||||||
return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {});
|
return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {});
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
|
||||||
}
|
}
|
||||||
var r = restaurantService.findById(id);
|
var r = restaurantService.findById(id);
|
||||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public class ReviewController {
|
|||||||
@PathVariable String restaurantId,
|
@PathVariable String restaurantId,
|
||||||
@RequestBody Map<String, Object> body) {
|
@RequestBody Map<String, Object> body) {
|
||||||
String userId = AuthUtil.getUserId();
|
String userId = AuthUtil.getUserId();
|
||||||
double rating = ((Number) body.get("rating")).doubleValue();
|
double rating = requireRating(body.get("rating"));
|
||||||
String text = (String) body.get("review_text");
|
String text = (String) body.get("review_text");
|
||||||
LocalDate visitedAt = body.get("visited_at") != null
|
LocalDate visitedAt = body.get("visited_at") != null
|
||||||
? LocalDate.parse((String) body.get("visited_at")) : null;
|
? LocalDate.parse((String) body.get("visited_at")) : null;
|
||||||
@@ -51,8 +51,7 @@ public class ReviewController {
|
|||||||
@PathVariable String reviewId,
|
@PathVariable String reviewId,
|
||||||
@RequestBody Map<String, Object> body) {
|
@RequestBody Map<String, Object> body) {
|
||||||
String userId = AuthUtil.getUserId();
|
String userId = AuthUtil.getUserId();
|
||||||
Double rating = body.get("rating") != null
|
Double rating = body.get("rating") != null ? requireRating(body.get("rating")) : null;
|
||||||
? ((Number) body.get("rating")).doubleValue() : null;
|
|
||||||
String text = (String) body.get("review_text");
|
String text = (String) body.get("review_text");
|
||||||
LocalDate visitedAt = body.get("visited_at") != null
|
LocalDate visitedAt = body.get("visited_at") != null
|
||||||
? LocalDate.parse((String) body.get("visited_at")) : null;
|
? LocalDate.parse((String) body.get("visited_at")) : null;
|
||||||
@@ -94,4 +93,18 @@ public class ReviewController {
|
|||||||
public List<Restaurant> myFavorites() {
|
public List<Restaurant> myFavorites() {
|
||||||
return reviewService.getUserFavorites(AuthUtil.getUserId());
|
return reviewService.getUserFavorites(AuthUtil.getUserId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #294 — rating 검증: null/비숫자/범위 외 입력은 400.
|
||||||
|
*/
|
||||||
|
private static double requireRating(Object raw) {
|
||||||
|
if (!(raw instanceof Number n)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "rating은 숫자여야 합니다");
|
||||||
|
}
|
||||||
|
double v = n.doubleValue();
|
||||||
|
if (v < 0.0 || v > 5.0 || Double.isNaN(v)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "rating은 0.0 ~ 5.0 범위여야 합니다");
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package com.tasteby.controller;
|
|||||||
|
|
||||||
import com.tasteby.domain.Restaurant;
|
import com.tasteby.domain.Restaurant;
|
||||||
import com.tasteby.service.SearchService;
|
import com.tasteby.service.SearchService;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -21,7 +23,12 @@ public class SearchController {
|
|||||||
@RequestParam String q,
|
@RequestParam String q,
|
||||||
@RequestParam(defaultValue = "keyword") String mode,
|
@RequestParam(defaultValue = "keyword") String mode,
|
||||||
@RequestParam(defaultValue = "20") int limit) {
|
@RequestParam(defaultValue = "20") int limit) {
|
||||||
|
// #293 — q 빈값 가드: '%%' LIKE로 응답 폭발 차단
|
||||||
|
if (q == null || q.isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "검색어가 필요합니다");
|
||||||
|
}
|
||||||
if (limit > 100) limit = 100;
|
if (limit > 100) limit = 100;
|
||||||
return searchService.search(q, mode, limit);
|
if (limit < 1) limit = 1;
|
||||||
|
return searchService.search(q.trim(), mode, limit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ public interface StatsMapper {
|
|||||||
|
|
||||||
void recordVisit();
|
void recordVisit();
|
||||||
|
|
||||||
int getTodayVisits();
|
long getTodayVisits();
|
||||||
|
|
||||||
int getTotalVisits();
|
long getTotalVisits();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import com.google.api.client.http.javanet.NetHttpTransport;
|
|||||||
import com.google.api.client.json.gson.GsonFactory;
|
import com.google.api.client.json.gson.GsonFactory;
|
||||||
import com.tasteby.domain.UserInfo;
|
import com.tasteby.domain.UserInfo;
|
||||||
import com.tasteby.security.JwtTokenProvider;
|
import com.tasteby.security.JwtTokenProvider;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -17,6 +19,8 @@ import java.util.Map;
|
|||||||
@Service
|
@Service
|
||||||
public class AuthService {
|
public class AuthService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
|
||||||
|
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final JwtTokenProvider jwtProvider;
|
private final JwtTokenProvider jwtProvider;
|
||||||
private final GoogleIdTokenVerifier verifier;
|
private final GoogleIdTokenVerifier verifier;
|
||||||
@@ -58,7 +62,10 @@ public class AuthService {
|
|||||||
} catch (ResponseStatusException e) {
|
} catch (ResponseStatusException e) {
|
||||||
throw e;
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid Google token: " + e.getMessage());
|
// #266 — 외부에는 고정 메시지만, 상세는 로그로 (Google verifier 내부 네트워크/공개키
|
||||||
|
// 조회 실패 메시지가 클라이언트에 노출되지 않도록)
|
||||||
|
log.warn("Google token verification failed: {}", e.getMessage());
|
||||||
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid Google token");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,15 @@ public class CacheService {
|
|||||||
this.redis = redis;
|
this.redis = redis;
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
this.ttl = Duration.ofSeconds(ttlSeconds);
|
this.ttl = Duration.ofSeconds(ttlSeconds);
|
||||||
try {
|
// #276 — ping 연결 자원 누수 방지: try-with-resources
|
||||||
redis.getConnectionFactory().getConnection().ping();
|
var factory = redis.getConnectionFactory();
|
||||||
|
if (factory == null) {
|
||||||
|
log.warn("Redis ConnectionFactory is null, caching disabled");
|
||||||
|
disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (var conn = factory.getConnection()) {
|
||||||
|
conn.ping();
|
||||||
log.info("Redis connected");
|
log.info("Redis connected");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Redis unavailable ({}), caching disabled", e.getMessage());
|
log.warn("Redis unavailable ({}), caching disabled", e.getMessage());
|
||||||
@@ -37,6 +44,13 @@ public class CacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String makeKey(String... parts) {
|
public String makeKey(String... parts) {
|
||||||
|
// #276 — null/빈 파트로 "tasteby::" 같은 잘못된 키 생성 방지
|
||||||
|
if (parts == null || parts.length == 0) {
|
||||||
|
throw new IllegalArgumentException("makeKey requires at least one part");
|
||||||
|
}
|
||||||
|
for (String p : parts) {
|
||||||
|
if (p == null) throw new IllegalArgumentException("makeKey parts must not be null");
|
||||||
|
}
|
||||||
return PREFIX + String.join(":", parts);
|
return PREFIX + String.join(":", parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,4 +99,14 @@ public class CacheService {
|
|||||||
log.debug("Cache flush error: {}", e.getMessage());
|
log.debug("Cache flush error: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #290 — 단일 키 삭제 (캐시 역직렬화 실패 시 자동 evict 등에 사용)
|
||||||
|
public void del(String key) {
|
||||||
|
if (disabled) return;
|
||||||
|
try {
|
||||||
|
redis.delete(key);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Cache del error: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,16 @@ public class ChannelService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean deactivate(String channelId) {
|
public boolean deactivate(String channelId) {
|
||||||
// Try deactivate by channel_id first, then by DB id
|
if (channelId == null || channelId.isBlank()) return false;
|
||||||
int rows = mapper.deactivateByChannelId(channelId);
|
// #295 — 입력 형식으로 명시적 분기:
|
||||||
if (rows == 0) {
|
// "UC..."(24 chars) 형식 → YouTube channel_id로 비활성화
|
||||||
rows = mapper.deactivateById(channelId);
|
// 그 외(32-char hex UUID 등) → DB id로 비활성화
|
||||||
}
|
// 이전: channel_id 시도 → 0이면 id 시도. 우연히 UC가 hex와 같을 확률은 0이지만
|
||||||
|
// 가독성/의도 명확성 + 잘못된 폴백 차단을 위해 명시화.
|
||||||
|
boolean looksLikeYouTubeId = channelId.startsWith("UC") && channelId.length() == 24;
|
||||||
|
int rows = looksLikeYouTubeId
|
||||||
|
? mapper.deactivateByChannelId(channelId)
|
||||||
|
: mapper.deactivateById(channelId);
|
||||||
return rows > 0;
|
return rows > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package com.tasteby.service;
|
|||||||
|
|
||||||
import com.tasteby.domain.DaemonConfig;
|
import com.tasteby.domain.DaemonConfig;
|
||||||
import com.tasteby.mapper.DaemonConfigMapper;
|
import com.tasteby.mapper.DaemonConfigMapper;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -27,20 +29,33 @@ public class DaemonConfigService {
|
|||||||
current.setScanEnabled(Boolean.TRUE.equals(body.get("scan_enabled")));
|
current.setScanEnabled(Boolean.TRUE.equals(body.get("scan_enabled")));
|
||||||
}
|
}
|
||||||
if (body.containsKey("scan_interval_min")) {
|
if (body.containsKey("scan_interval_min")) {
|
||||||
current.setScanIntervalMin(((Number) body.get("scan_interval_min")).intValue());
|
// #275 — 0/음수 입력으로 30초 사이클 폭주 방지. ClassCastException 대신 400.
|
||||||
|
current.setScanIntervalMin(requirePositiveInt(body.get("scan_interval_min"), "scan_interval_min"));
|
||||||
}
|
}
|
||||||
if (body.containsKey("process_enabled")) {
|
if (body.containsKey("process_enabled")) {
|
||||||
current.setProcessEnabled(Boolean.TRUE.equals(body.get("process_enabled")));
|
current.setProcessEnabled(Boolean.TRUE.equals(body.get("process_enabled")));
|
||||||
}
|
}
|
||||||
if (body.containsKey("process_interval_min")) {
|
if (body.containsKey("process_interval_min")) {
|
||||||
current.setProcessIntervalMin(((Number) body.get("process_interval_min")).intValue());
|
current.setProcessIntervalMin(requirePositiveInt(body.get("process_interval_min"), "process_interval_min"));
|
||||||
}
|
}
|
||||||
if (body.containsKey("process_limit")) {
|
if (body.containsKey("process_limit")) {
|
||||||
current.setProcessLimit(((Number) body.get("process_limit")).intValue());
|
current.setProcessLimit(requirePositiveInt(body.get("process_limit"), "process_limit"));
|
||||||
}
|
}
|
||||||
mapper.updateConfig(current);
|
mapper.updateConfig(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** #275 — 양의 정수 가드. 비숫자/0/음수는 400. */
|
||||||
|
private static int requirePositiveInt(Object raw, String field) {
|
||||||
|
if (!(raw instanceof Number n)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + "은(는) 정수여야 합니다");
|
||||||
|
}
|
||||||
|
int v = n.intValue();
|
||||||
|
if (v < 1) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + "은(는) 1 이상이어야 합니다 (폭주 방지)");
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
public void updateLastScan() {
|
public void updateLastScan() {
|
||||||
mapper.updateLastScan();
|
mapper.updateLastScan();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.tasteby.domain.Review;
|
|||||||
import com.tasteby.mapper.ReviewMapper;
|
import com.tasteby.mapper.ReviewMapper;
|
||||||
import com.tasteby.util.IdGenerator;
|
import com.tasteby.util.IdGenerator;
|
||||||
import com.tasteby.util.JsonUtil;
|
import com.tasteby.util.JsonUtil;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -60,10 +61,15 @@ public class ReviewService {
|
|||||||
if (existingId != null) {
|
if (existingId != null) {
|
||||||
mapper.deleteFavorite(userId, restaurantId);
|
mapper.deleteFavorite(userId, restaurantId);
|
||||||
return false;
|
return false;
|
||||||
} else {
|
|
||||||
mapper.insertFavorite(IdGenerator.newId(), userId, restaurantId);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
// #294 — 동시성 가드: 동시 INSERT 시 UNIQUE 충돌 → 한 쪽 500.
|
||||||
|
// INSERT 시도 후 DuplicateKeyException은 "이미 추가됨"으로 간주 (토글 의도는 ON).
|
||||||
|
try {
|
||||||
|
mapper.insertFavorite(IdGenerator.newId(), userId, restaurantId);
|
||||||
|
} catch (DuplicateKeyException ignored) {
|
||||||
|
// 다른 트랜잭션이 먼저 INSERT 함 — 결과는 어쨌든 즐겨찾기 ON.
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Restaurant> getUserFavorites(String userId) {
|
public List<Restaurant> getUserFavorites(String userId) {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.tasteby.service;
|
package com.tasteby.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.tasteby.domain.Restaurant;
|
import com.tasteby.domain.Restaurant;
|
||||||
import com.tasteby.mapper.SearchMapper;
|
import com.tasteby.mapper.SearchMapper;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@@ -12,12 +15,17 @@ import java.util.*;
|
|||||||
public class SearchService {
|
public class SearchService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(SearchService.class);
|
private static final Logger log = LoggerFactory.getLogger(SearchService.class);
|
||||||
|
private static final ObjectMapper JSON = new ObjectMapper();
|
||||||
|
private static final TypeReference<List<Restaurant>> LIST_TYPE = new TypeReference<>() {};
|
||||||
|
|
||||||
private final SearchMapper searchMapper;
|
private final SearchMapper searchMapper;
|
||||||
private final RestaurantService restaurantService;
|
private final RestaurantService restaurantService;
|
||||||
private final VectorService vectorService;
|
private final VectorService vectorService;
|
||||||
private final CacheService cache;
|
private final CacheService cache;
|
||||||
|
|
||||||
|
@Value("${app.search.max-distance:0.57}")
|
||||||
|
private double maxDistance;
|
||||||
|
|
||||||
public SearchService(SearchMapper searchMapper,
|
public SearchService(SearchMapper searchMapper,
|
||||||
RestaurantService restaurantService,
|
RestaurantService restaurantService,
|
||||||
VectorService vectorService,
|
VectorService vectorService,
|
||||||
@@ -33,8 +41,8 @@ public class SearchService {
|
|||||||
String cached = cache.getRaw(key);
|
String cached = cache.getRaw(key);
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
try {
|
try {
|
||||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
// #293 — ObjectMapper 재사용 (필드 static)
|
||||||
return mapper.readValue(cached, new com.fasterxml.jackson.core.type.TypeReference<List<Restaurant>>() {});
|
return JSON.readValue(cached, LIST_TYPE);
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,13 +52,20 @@ public class SearchService {
|
|||||||
case "hybrid" -> {
|
case "hybrid" -> {
|
||||||
var kw = keywordSearch(q, limit);
|
var kw = keywordSearch(q, limit);
|
||||||
var sem = semanticSearch(q, limit);
|
var sem = semanticSearch(q, limit);
|
||||||
|
// #293 — semantic 결과에도 channels 부착 (이전: keyword에만 부착되어 hybrid에서 sem 결과는 채널 누락)
|
||||||
|
if (!sem.isEmpty()) attachChannels(sem);
|
||||||
Set<String> seen = new HashSet<>();
|
Set<String> seen = new HashSet<>();
|
||||||
var merged = new ArrayList<Restaurant>();
|
var merged = new ArrayList<Restaurant>();
|
||||||
for (var r : kw) { if (seen.add(r.getId())) merged.add(r); }
|
for (var r : kw) { if (seen.add(r.getId())) merged.add(r); }
|
||||||
for (var r : sem) { if (seen.add(r.getId())) merged.add(r); }
|
for (var r : sem) { if (seen.add(r.getId())) merged.add(r); }
|
||||||
result = merged.size() > limit ? merged.subList(0, limit) : merged;
|
result = merged.size() > limit ? merged.subList(0, limit) : merged;
|
||||||
}
|
}
|
||||||
default -> result = keywordSearch(q, limit);
|
case "keyword" -> result = keywordSearch(q, limit);
|
||||||
|
default -> {
|
||||||
|
// #293 — 알 수 없는 mode는 silent fallback 대신 경고 로그
|
||||||
|
log.warn("Unknown search mode '{}', falling back to keyword", mode);
|
||||||
|
result = keywordSearch(q, limit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cache.set(key, result);
|
cache.set(key, result);
|
||||||
@@ -58,7 +73,10 @@ public class SearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<Restaurant> keywordSearch(String q, int limit) {
|
private List<Restaurant> keywordSearch(String q, int limit) {
|
||||||
String pattern = "%" + q + "%";
|
// #293 — LIKE 와일드카드 escape: 사용자 입력의 %, _, \ 를 리터럴로 처리.
|
||||||
|
// SQL에서는 ESCAPE '\\' 절을 사용 (SearchMapper.xml).
|
||||||
|
String escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
|
||||||
|
String pattern = "%" + escaped + "%";
|
||||||
List<Restaurant> results = searchMapper.keywordSearch(pattern, limit);
|
List<Restaurant> results = searchMapper.keywordSearch(pattern, limit);
|
||||||
if (!results.isEmpty()) {
|
if (!results.isEmpty()) {
|
||||||
attachChannels(results);
|
attachChannels(results);
|
||||||
@@ -68,7 +86,7 @@ public class SearchService {
|
|||||||
|
|
||||||
private List<Restaurant> semanticSearch(String q, int limit) {
|
private List<Restaurant> semanticSearch(String q, int limit) {
|
||||||
try {
|
try {
|
||||||
var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), 0.57);
|
var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), maxDistance);
|
||||||
if (similar.isEmpty()) return List.of();
|
if (similar.isEmpty()) return List.of();
|
||||||
|
|
||||||
Set<String> seen = new LinkedHashSet<>();
|
Set<String> seen = new LinkedHashSet<>();
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ package com.tasteby.service;
|
|||||||
|
|
||||||
import com.tasteby.domain.SiteVisitStats;
|
import com.tasteby.domain.SiteVisitStats;
|
||||||
import com.tasteby.mapper.StatsMapper;
|
import com.tasteby.mapper.StatsMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class StatsService {
|
public class StatsService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(StatsService.class);
|
||||||
|
|
||||||
private final StatsMapper mapper;
|
private final StatsMapper mapper;
|
||||||
|
|
||||||
public StatsService(StatsMapper mapper) {
|
public StatsService(StatsMapper mapper) {
|
||||||
@@ -14,7 +19,19 @@ public class StatsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void recordVisit() {
|
public void recordVisit() {
|
||||||
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() {
|
||||||
|
|||||||
@@ -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 = """
|
||||||
|
|||||||
@@ -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만 동작시킨다.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -79,7 +79,8 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="getAvgRating" resultType="map">
|
<select id="getAvgRating" resultType="map">
|
||||||
SELECT ROUND(AVG(rating), 1) AS avg_rating, COUNT(*) AS review_count
|
<!-- #294 — review 0건이면 AVG는 NULL → 클라이언트 NaN 처리 부담. NVL로 0.0 보장. -->
|
||||||
|
SELECT NVL(ROUND(AVG(rating), 1), 0) AS avg_rating, COUNT(*) AS review_count
|
||||||
FROM user_reviews
|
FROM user_reviews
|
||||||
WHERE restaurant_id = #{restaurantId}
|
WHERE restaurant_id = #{restaurantId}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -30,12 +30,13 @@
|
|||||||
JOIN video_restaurants vr ON vr.restaurant_id = r.id
|
JOIN video_restaurants vr ON vr.restaurant_id = r.id
|
||||||
JOIN videos v ON v.id = vr.video_id
|
JOIN videos v ON v.id = vr.video_id
|
||||||
WHERE r.latitude IS NOT NULL
|
WHERE r.latitude IS NOT NULL
|
||||||
AND (UPPER(r.name) LIKE UPPER(#{query})
|
<!-- #293 — ESCAPE 절로 사용자 입력의 %, _ 와일드카드 의도 우회 차단 -->
|
||||||
OR UPPER(r.address) LIKE UPPER(#{query})
|
AND (UPPER(r.name) LIKE UPPER(#{query}) ESCAPE '\'
|
||||||
OR UPPER(r.region) LIKE UPPER(#{query})
|
OR UPPER(r.address) LIKE UPPER(#{query}) ESCAPE '\'
|
||||||
OR UPPER(r.cuisine_type) LIKE UPPER(#{query})
|
OR UPPER(r.region) LIKE UPPER(#{query}) ESCAPE '\'
|
||||||
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query})
|
OR UPPER(r.cuisine_type) LIKE UPPER(#{query}) ESCAPE '\'
|
||||||
OR UPPER(v.title) LIKE UPPER(#{query}))
|
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query}) ESCAPE '\'
|
||||||
|
OR UPPER(v.title) LIKE UPPER(#{query}) ESCAPE '\')
|
||||||
FETCH FIRST #{limit} ROWS ONLY
|
FETCH FIRST #{limit} ROWS ONLY
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -67,8 +67,7 @@ type RestaurantProps = { restaurant: Restaurant };
|
|||||||
type RestaurantFeature = Supercluster.PointFeature<RestaurantProps>;
|
type RestaurantFeature = Supercluster.PointFeature<RestaurantProps>;
|
||||||
|
|
||||||
function useSupercluster(restaurants: Restaurant[]) {
|
function useSupercluster(restaurants: Restaurant[]) {
|
||||||
const indexRef = useRef<Supercluster<{ restaurant: Restaurant }> | null>(null);
|
// #278 — indexRef 제거 (set만 되고 read 없는 dead code)
|
||||||
|
|
||||||
const points: RestaurantFeature[] = useMemo(
|
const points: RestaurantFeature[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
restaurants.map((r) => ({
|
restaurants.map((r) => ({
|
||||||
@@ -86,7 +85,6 @@ function useSupercluster(restaurants: Restaurant[]) {
|
|||||||
minPoints: 2,
|
minPoints: 2,
|
||||||
});
|
});
|
||||||
sc.load(points);
|
sc.load(points);
|
||||||
indexRef.current = sc;
|
|
||||||
return sc;
|
return sc;
|
||||||
}, [points]);
|
}, [points]);
|
||||||
|
|
||||||
@@ -129,12 +127,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
|
|||||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||||
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
|
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
|
||||||
|
|
||||||
// Build a lookup for restaurants by id
|
// #278 — restaurantMap 제거 (빌드만 되고 렌더에서 사용 안 됨, dead code)
|
||||||
const restaurantMap = useMemo(() => {
|
|
||||||
const m: Record<string, Restaurant> = {};
|
|
||||||
restaurants.forEach((r) => { m[r.id] = r; });
|
|
||||||
return m;
|
|
||||||
}, [restaurants]);
|
|
||||||
|
|
||||||
const clusters = useMemo(() => {
|
const clusters = useMemo(() => {
|
||||||
if (!bounds) return [];
|
if (!bounds) return [];
|
||||||
@@ -273,7 +266,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
|
|||||||
textDecoration: isClosed ? "line-through" : "none",
|
textDecoration: isClosed ? "line-through" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-rounded" style={{ fontSize: 14, marginRight: 3, verticalAlign: "middle", color: "#E8720C" }}>{getCuisineIcon(r.cuisine_type)}</span>
|
<span className="material-symbols-rounded" style={{ fontSize: 14, width: 14, height: 14, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center", marginRight: 3, verticalAlign: "middle", color: "#E8720C" }}>{getCuisineIcon(r.cuisine_type)}</span>
|
||||||
{r.name}
|
{r.name}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -298,7 +291,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
|
|||||||
>
|
>
|
||||||
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
|
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-bold text-base" style={{ color: "#171717" }}><span className="material-symbols-rounded" style={{ fontSize: 18, verticalAlign: "middle", color: "#E8720C", marginRight: 4 }}>{getCuisineIcon(infoTarget.cuisine_type)}</span>{infoTarget.name}</h3>
|
<h3 className="font-bold text-base" style={{ color: "#171717" }}><span className="material-symbols-rounded" style={{ fontSize: 18, width: 18, height: 18, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center", verticalAlign: "middle", color: "#E8720C", marginRight: 4 }}>{getCuisineIcon(infoTarget.cuisine_type)}</span>{infoTarget.name}</h3>
|
||||||
{infoTarget.business_status === "CLOSED_PERMANENTLY" && (
|
{infoTarget.business_status === "CLOSED_PERMANENTLY" && (
|
||||||
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold">폐업</span>
|
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold">폐업</span>
|
||||||
)}
|
)}
|
||||||
@@ -357,6 +350,13 @@ export default function MapView({ restaurants, selected, onSelectRestaurant, onB
|
|||||||
}, 150);
|
}, 150);
|
||||||
}, [onBoundsChanged]);
|
}, [onBoundsChanged]);
|
||||||
|
|
||||||
|
// #278 — 언마운트 시 디바운스 타이머 정리 (메모리 누수 + unmounted setState 경고 방지)
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<APIProvider apiKey={API_KEY}>
|
<APIProvider apiKey={API_KEY}>
|
||||||
<Map
|
<Map
|
||||||
@@ -380,10 +380,12 @@ export default function MapView({ restaurants, selected, onSelectRestaurant, onB
|
|||||||
{onMyLocation && (
|
{onMyLocation && (
|
||||||
<button
|
<button
|
||||||
onClick={onMyLocation}
|
onClick={onMyLocation}
|
||||||
className="absolute top-2 right-2 w-9 h-9 bg-surface rounded-lg shadow-md flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-brand-500 dark:hover:text-brand-400 transition-colors z-10"
|
aria-label="내 위치로 이동"
|
||||||
|
// #278 — 44×44px 터치 영역 확보 (이전 36px)
|
||||||
|
className="absolute top-2 right-2 w-11 h-11 bg-surface rounded-lg shadow-md flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-brand-500 dark:hover:text-brand-400 transition-colors z-10 touch-manipulation"
|
||||||
title="내 위치"
|
title="내 위치"
|
||||||
>
|
>
|
||||||
<Icon name="my_location" size={20} />
|
<Icon name="my_location" size={22} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{channelNames.length > 0 && (
|
{channelNames.length > 0 && (
|
||||||
|
|||||||
52
frontend/src/lib/admin-utils.ts
Normal file
52
frontend/src/lib/admin-utils.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// #304 어드민 페이지 공통 유틸.
|
||||||
|
// 결함: localStorage 직접 접근 10+곳 / SSE 파싱 코드 6곳 중복.
|
||||||
|
|
||||||
|
const TOKEN_KEY = "tasteby_token";
|
||||||
|
|
||||||
|
export function getAdminToken(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authHeaders(): Record<string, string> {
|
||||||
|
const token = getAdminToken();
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSE(Server-Sent Events) 스트림을 라인 단위로 파싱하여 onEvent 콜백을 호출.
|
||||||
|
* 호환 패턴: `data: { ...json... }` 한 줄 = 한 이벤트.
|
||||||
|
* 비어있는 줄은 무시. JSON 파싱 실패 시 콜백 skip.
|
||||||
|
*/
|
||||||
|
export async function consumeSseStream(
|
||||||
|
response: Response,
|
||||||
|
onEvent: (event: unknown) => void,
|
||||||
|
onError?: (err: unknown) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) return;
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith("data:")) continue;
|
||||||
|
const payload = trimmed.slice(5).trim();
|
||||||
|
if (!payload) continue;
|
||||||
|
try {
|
||||||
|
onEvent(JSON.parse(payload));
|
||||||
|
} catch {
|
||||||
|
// 무시: 일부 SSE 줄이 JSON이 아닐 수도 있음
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
onError?.(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,10 @@ export interface Restaurant {
|
|||||||
website: string | null;
|
website: string | null;
|
||||||
channels?: string[];
|
channels?: string[];
|
||||||
foods_mentioned?: string[];
|
foods_mentioned?: string[];
|
||||||
|
// #322 LLM 검증
|
||||||
|
hidden?: boolean;
|
||||||
|
hidden_reason?: string | null;
|
||||||
|
verified_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoLink {
|
export interface VideoLink {
|
||||||
@@ -310,6 +314,7 @@ export const api = {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
nickname: string | null;
|
nickname: string | null;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
|
is_admin: boolean;
|
||||||
provider: string | null;
|
provider: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
favorite_count: number;
|
favorite_count: number;
|
||||||
@@ -320,6 +325,14 @@ export const api = {
|
|||||||
}>(`/api/admin/users${qs ? `?${qs}` : ""}`);
|
}>(`/api/admin/users${qs ? `?${qs}` : ""}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateAdminUserAdmin(userId: string, admin: boolean) {
|
||||||
|
return fetchApi<{ success: boolean }>(`/api/admin/users/${userId}/admin`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ admin }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
getAdminUserFavorites(userId: string) {
|
getAdminUserFavorites(userId: string) {
|
||||||
return fetchApi<
|
return fetchApi<
|
||||||
{
|
{
|
||||||
@@ -567,4 +580,30 @@ export const api = {
|
|||||||
{ method: "POST" }
|
{ method: "POST" }
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// #322 — LLM 검증 어드민 API
|
||||||
|
getVerifyPending() {
|
||||||
|
return fetchApi<{ pending: number }>("/api/admin/restaurants/verify/pending");
|
||||||
|
},
|
||||||
|
verifyAll(batchSize: number = 10) {
|
||||||
|
return fetchApi<{ processed: number }>(
|
||||||
|
`/api/admin/restaurants/verify/all?batchSize=${batchSize}`,
|
||||||
|
{ method: "POST" }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
verifyOne(id: string) {
|
||||||
|
return fetchApi<{ success: boolean; id: string }>(
|
||||||
|
`/api/admin/restaurants/${id}/verify`,
|
||||||
|
{ method: "POST" }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
setRestaurantHidden(id: string, hidden: boolean, reason: string = "manual") {
|
||||||
|
return fetchApi<{ success: boolean; id: string; hidden: boolean }>(
|
||||||
|
`/api/admin/restaurants/${id}/hidden`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ hidden, reason }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user