Compare commits
7 Commits
v0.1.17
...
5579c5b00f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5579c5b00f | ||
|
|
4b02293046 | ||
|
|
eb1eaa91a6 | ||
|
|
9c2dc9f43a | ||
|
|
7779d5ddfd | ||
|
|
6ea82a5561 | ||
|
|
04c54d1b1a |
38
CHANGELOG.md
38
CHANGELOG.md
@@ -6,6 +6,44 @@
|
|||||||
|
|
||||||
## 2026-06-15
|
## 2026-06-15
|
||||||
|
|
||||||
|
### 🧱 P4-1 백엔드 CRUD 결함 (v0.1.20)
|
||||||
|
- #294: MemoService/ReviewService 동시성 DuplicateKeyException 가드, rating 0~5 검증, getAvgRating NVL
|
||||||
|
- #295: 유니크 충돌 typed exception, channel_id "UC..." 형식 명시 분기, findByChannelId 컬럼 보완, body null 가드
|
||||||
|
- #290: @PreDestroy executor shutdown, 캐시 silent → log.warn + cache.del, tabling/catchtable URL 스킴 화이트리스트
|
||||||
|
- 후속 분리: #332(#290), #333(#295), #334(#294) — DTO/DDG/세분화/테스트
|
||||||
|
- Refs: #290 #294 #295 (close)
|
||||||
|
|
||||||
|
### 🔍 #293 검색/벡터 결함 7건 (v0.1.19)
|
||||||
|
- SearchController: q 빈값 400 가드 (`%%` 응답 폭발 차단)
|
||||||
|
- SearchService: LIKE 와일드카드 escape (%, _, \), hybrid 모드에서 sem 결과에도 채널 부착
|
||||||
|
- SearchService: ObjectMapper/TypeReference static 재사용, 알 수 없는 mode warn 로그
|
||||||
|
- SearchService: maxDistance를 @Value("${app.search.max-distance:0.57}") 외부화 (env SEARCH_MAX_DISTANCE)
|
||||||
|
- SearchMapper.xml: LIKE 절에 ESCAPE '\' 추가
|
||||||
|
- VectorService: embeddings null/empty 가드 (NPE 차단)
|
||||||
|
- 후속 분리: #331 (batch insert + 테스트)
|
||||||
|
- Refs: #293 (close)
|
||||||
|
|
||||||
|
### 🛠 #304+#323 어드민 LLM 검증 UI + 공통 유틸 (v0.1.18)
|
||||||
|
- 신규 frontend/src/lib/admin-utils.ts:
|
||||||
|
- getAdminToken / authHeaders / consumeSseStream
|
||||||
|
- api.ts: Restaurant 타입에 hidden/hidden_reason/verified_at + verify/setRestaurantHidden API 4개
|
||||||
|
- RestaurantsPanel:
|
||||||
|
- 헤더: "미검증 N건 + LLM 검증" 버튼
|
||||||
|
- 테이블: 검증 컬럼 (숨김/OK/미검증 배지 + 클릭으로 토글)
|
||||||
|
- colSpan 7로 수정
|
||||||
|
- 후속 분리: #329 (admin 전체 파일 분리 + localStorage/SSE 11+곳 통일)
|
||||||
|
- Refs: #304 #323 #322 (close)
|
||||||
|
|
||||||
|
### 🔧 #291+#292 백엔드 결함 일괄 수정 (v0.1.17)
|
||||||
|
- ExtractorService: transcript null/blank 가드 (NPE 방지)
|
||||||
|
- PipelineService.processExtract: 진입 시 status='processing' 명시 전이 (SSE/사용자 가시성)
|
||||||
|
- PipelineService: geocode 실패 시 좌표/place_id/주소 컬럼을 data에 put하지 않아 upsert COALESCE 보존 의도 명확화
|
||||||
|
- GeocodingService.parseRegionFromAddress: 빈 토큰을 region 문자열에서 제거 ('한국||구' 깨짐 방지)
|
||||||
|
- VideoService.saveVideosBatch: @Transactional 추가 → batch insert 원자성
|
||||||
|
- .gitignore: backend-java/cookies.txt 및 **/cookies.txt
|
||||||
|
- 후속 분리: #325 (#291 잔여 MINOR), #326 (parseJson 최적화 + #292 MINOR)
|
||||||
|
- Refs: #291 #292 (close)
|
||||||
|
|
||||||
### 🧹 #322 LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김 (v0.1.16)
|
### 🧹 #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,16 +53,21 @@ public class ChannelController {
|
|||||||
String channelId = body.get("channel_id");
|
String channelId = body.get("channel_id");
|
||||||
String channelName = body.get("channel_name");
|
String channelName = body.get("channel_name");
|
||||||
String titleFilter = body.get("title_filter");
|
String titleFilter = body.get("title_filter");
|
||||||
|
// #295 — body 필수값 가드 (NOT NULL 컬럼에 빈 값 들어가 500 나는 것 방지)
|
||||||
|
if (channelId == null || channelId.isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "channel_id는 필수입니다");
|
||||||
|
}
|
||||||
|
if (channelName == null || channelName.isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "channel_name은 필수입니다");
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
String id = channelService.create(channelId, channelName, titleFilter);
|
String id = channelService.create(channelId, channelName, titleFilter);
|
||||||
cache.flush();
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{channelId}/scan")
|
@PostMapping("/{channelId}/scan")
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,4 +85,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
try {
|
||||||
mapper.insertMemo(IdGenerator.newId(), userId, restaurantId, rating, memoText, visitedStr);
|
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<>();
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
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