Compare commits
13 Commits
04c54d1b1a
...
v0.1.25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
437e709a8d | ||
|
|
dcebb9f06f | ||
|
|
bff3dcc200 | ||
|
|
ea8db4bef3 | ||
|
|
ed076411ed | ||
|
|
865cd86aff | ||
|
|
c6428e5d5f | ||
|
|
5579c5b00f | ||
|
|
4b02293046 | ||
|
|
eb1eaa91a6 | ||
|
|
9c2dc9f43a | ||
|
|
7779d5ddfd | ||
|
|
6ea82a5561 |
49
CHANGELOG.md
49
CHANGELOG.md
@@ -6,6 +6,55 @@
|
||||
|
||||
## 2026-06-15
|
||||
|
||||
### ⭐ P4-4 별점 공통화 + 로그인 모달 접근성 (v0.1.23)
|
||||
- #281: 공통 Stars 컴포넌트 (0.5단위 절반 채우기), StarSelector role=radiogroup + 44px + 반쪽 별 ⯨, try/catch + alert
|
||||
- #283: LoginMenu에 useEscapeKey/useFocusTrap/useBodyScrollLock 훅 적용, role=dialog/aria-modal/aria-labelledby, onError 인라인 alert
|
||||
- MyReviewsList: Math.round → Stars (0.5단위 정확 렌더)
|
||||
- 후속 분리: #343(next/image, ARIA Tabs, 테스트), #344(z-index 토큰, i18n)
|
||||
- Refs: #281 #283 (close)
|
||||
|
||||
### 🔐 P4-3 인증 메시지 + 지도 접근성 (v0.1.22)
|
||||
- #266: Google verifier 실패 메시지 고정 + log.warn (정보 누출 차단)
|
||||
- #278: boundsTimerRef cleanup, '내 위치' 44px + aria-label, dead code 제거
|
||||
- #277: 결함 모두 후속(#338) — deep health/version/테스트는 별도
|
||||
- 후속 분리: #338(deep health), #339(브랜드 토큰화/마커 ARIA), #340(다중 audience)
|
||||
- Refs: #266 #277 #278 (close)
|
||||
|
||||
### ⚙️ P4-2 데몬/캐시/통계 결함 (v0.1.21)
|
||||
- #275: updateConfig 가드(1+ 정수), Scheduler try-finally updateLastX, GET config admin-only
|
||||
- #276: ping try-with-resources + ConnectionFactory null 가드, makeKey null 가드
|
||||
- #274: SiteVisitStats int → long, recordVisit DataIntegrityViolationException 1회 재시도
|
||||
- 후속 분리: #335 (분산락), #336 (SCAN/자동복구), #337 (봇/레이트리밋)
|
||||
- Refs: #275 #276 #274 (close)
|
||||
|
||||
### 🧱 P4-1 백엔드 CRUD 결함 (v0.1.20)
|
||||
- #294: MemoService/ReviewService 동시성 DuplicateKeyException 가드, rating 0~5 검증, getAvgRating NVL
|
||||
- #295: 유니크 충돌 typed exception, channel_id "UC..." 형식 명시 분기, findByChannelId 컬럼 보완, body null 가드
|
||||
- #290: @PreDestroy executor shutdown, 캐시 silent → log.warn + cache.del, tabling/catchtable URL 스킴 화이트리스트
|
||||
- 후속 분리: #332(#290), #333(#295), #334(#294) — DTO/DDG/세분화/테스트
|
||||
- Refs: #290 #294 #295 (close)
|
||||
|
||||
### 🔍 #293 검색/벡터 결함 7건 (v0.1.19)
|
||||
- SearchController: q 빈값 400 가드 (`%%` 응답 폭발 차단)
|
||||
- SearchService: LIKE 와일드카드 escape (%, _, \), hybrid 모드에서 sem 결과에도 채널 부착
|
||||
- SearchService: ObjectMapper/TypeReference static 재사용, 알 수 없는 mode warn 로그
|
||||
- SearchService: maxDistance를 @Value("${app.search.max-distance:0.57}") 외부화 (env SEARCH_MAX_DISTANCE)
|
||||
- SearchMapper.xml: LIKE 절에 ESCAPE '\' 추가
|
||||
- VectorService: embeddings null/empty 가드 (NPE 차단)
|
||||
- 후속 분리: #331 (batch insert + 테스트)
|
||||
- Refs: #293 (close)
|
||||
|
||||
### 🛠 #304+#323 어드민 LLM 검증 UI + 공통 유틸 (v0.1.18)
|
||||
- 신규 frontend/src/lib/admin-utils.ts:
|
||||
- getAdminToken / authHeaders / consumeSseStream
|
||||
- api.ts: Restaurant 타입에 hidden/hidden_reason/verified_at + verify/setRestaurantHidden API 4개
|
||||
- RestaurantsPanel:
|
||||
- 헤더: "미검증 N건 + LLM 검증" 버튼
|
||||
- 테이블: 검증 컬럼 (숨김/OK/미검증 배지 + 클릭으로 토글)
|
||||
- colSpan 7로 수정
|
||||
- 후속 분리: #329 (admin 전체 파일 분리 + localStorage/SSE 11+곳 통일)
|
||||
- Refs: #304 #323 #322 (close)
|
||||
|
||||
### 🔧 #291+#292 백엔드 결함 일괄 수정 (v0.1.17)
|
||||
- ExtractorService: transcript null/blank 가드 (NPE 방지)
|
||||
- PipelineService.processExtract: 진입 시 status='processing' 명시 전이 (SSE/사용자 가시성)
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.CacheService;
|
||||
import com.tasteby.service.ChannelService;
|
||||
import com.tasteby.service.YouTubeService;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
@@ -52,15 +53,20 @@ public class ChannelController {
|
||||
String channelId = body.get("channel_id");
|
||||
String channelName = body.get("channel_name");
|
||||
String titleFilter = body.get("title_filter");
|
||||
// #295 — body 필수값 가드 (NOT NULL 컬럼에 빈 값 들어가 500 나는 것 방지)
|
||||
if (channelId == null || channelId.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "channel_id는 필수입니다");
|
||||
}
|
||||
if (channelName == null || channelName.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "channel_name은 필수입니다");
|
||||
}
|
||||
try {
|
||||
String id = channelService.create(channelId, channelName, titleFilter);
|
||||
cache.flush();
|
||||
return Map.of("id", id, "channel_id", channelId);
|
||||
} catch (Exception e) {
|
||||
if (e.getMessage() != null && e.getMessage().toUpperCase().contains("UQ_CHANNELS_CID")) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Channel already exists");
|
||||
}
|
||||
throw e;
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
// #295 — 유니크 충돌을 메시지 문자열 매칭 대신 typed 예외로 감지 (제약명 변경에도 견고).
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Channel already exists");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ public class DaemonController {
|
||||
|
||||
@GetMapping("/config")
|
||||
public DaemonConfig getConfig() {
|
||||
// #275 — 데몬 운영 설정은 admin 전용 (이전: 공개 노출 — 정보 누출 위험)
|
||||
AuthUtil.requireAdmin();
|
||||
DaemonConfig config = daemonConfigService.getConfig();
|
||||
return config != null ? config : DaemonConfig.builder().build();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.CacheService;
|
||||
import com.tasteby.service.GeocodingService;
|
||||
import com.tasteby.service.RestaurantService;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -47,6 +48,12 @@ public class RestaurantController {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
// #290 — Bean 종료 시 virtual thread executor를 정리하여 리소스 누수 방지.
|
||||
@PreDestroy
|
||||
public void shutdownExecutor() {
|
||||
executor.shutdown();
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Restaurant> list(
|
||||
@RequestParam(defaultValue = "100") int limit,
|
||||
@@ -61,7 +68,7 @@ public class RestaurantController {
|
||||
if (cached != null) {
|
||||
try {
|
||||
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
|
||||
} catch (Exception ignored) {}
|
||||
} catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
|
||||
}
|
||||
var result = restaurantService.findAll(limit, offset, cuisine, region, channel);
|
||||
cache.set(key, result);
|
||||
@@ -75,7 +82,7 @@ public class RestaurantController {
|
||||
if (cached != null) {
|
||||
try {
|
||||
return objectMapper.readValue(cached, Restaurant.class);
|
||||
} catch (Exception ignored) {}
|
||||
} catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
|
||||
}
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
@@ -241,6 +248,10 @@ public class RestaurantController {
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
String url = body.get("tabling_url");
|
||||
// #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
|
||||
if (url != null && !url.isBlank() && !url.startsWith("https://tabling.co.kr/")) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "테이블링 URL은 https://tabling.co.kr/ 만 허용");
|
||||
}
|
||||
restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
@@ -367,6 +378,12 @@ public class RestaurantController {
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
String url = body.get("catchtable_url");
|
||||
// #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
|
||||
if (url != null && !url.isBlank()
|
||||
&& !url.startsWith("https://app.catchtable.co.kr/")
|
||||
&& !url.startsWith("https://www.catchtable.co.kr/")) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "캐치테이블 URL은 https://(app|www).catchtable.co.kr/ 만 허용");
|
||||
}
|
||||
restaurantService.update(id, Map.of("catchtable_url", url != null ? url : ""));
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
@@ -379,7 +396,7 @@ public class RestaurantController {
|
||||
if (cached != null) {
|
||||
try {
|
||||
return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {});
|
||||
} catch (Exception ignored) {}
|
||||
} catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
|
||||
}
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
|
||||
@@ -39,7 +39,7 @@ public class ReviewController {
|
||||
@PathVariable String restaurantId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String userId = AuthUtil.getUserId();
|
||||
double rating = ((Number) body.get("rating")).doubleValue();
|
||||
double rating = requireRating(body.get("rating"));
|
||||
String text = (String) body.get("review_text");
|
||||
LocalDate visitedAt = body.get("visited_at") != null
|
||||
? LocalDate.parse((String) body.get("visited_at")) : null;
|
||||
@@ -51,8 +51,7 @@ public class ReviewController {
|
||||
@PathVariable String reviewId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String userId = AuthUtil.getUserId();
|
||||
Double rating = body.get("rating") != null
|
||||
? ((Number) body.get("rating")).doubleValue() : null;
|
||||
Double rating = body.get("rating") != null ? requireRating(body.get("rating")) : null;
|
||||
String text = (String) body.get("review_text");
|
||||
LocalDate visitedAt = body.get("visited_at") != null
|
||||
? LocalDate.parse((String) body.get("visited_at")) : null;
|
||||
@@ -94,4 +93,18 @@ public class ReviewController {
|
||||
public List<Restaurant> myFavorites() {
|
||||
return reviewService.getUserFavorites(AuthUtil.getUserId());
|
||||
}
|
||||
|
||||
/**
|
||||
* #294 — rating 검증: null/비숫자/범위 외 입력은 400.
|
||||
*/
|
||||
private static double requireRating(Object raw) {
|
||||
if (!(raw instanceof Number n)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "rating은 숫자여야 합니다");
|
||||
}
|
||||
double v = n.doubleValue();
|
||||
if (v < 0.0 || v > 5.0 || Double.isNaN(v)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "rating은 0.0 ~ 5.0 범위여야 합니다");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.service.SearchService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -21,7 +23,12 @@ public class SearchController {
|
||||
@RequestParam String q,
|
||||
@RequestParam(defaultValue = "keyword") String mode,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
// #293 — q 빈값 가드: '%%' LIKE로 응답 폭발 차단
|
||||
if (q == null || q.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "검색어가 필요합니다");
|
||||
}
|
||||
if (limit > 100) limit = 100;
|
||||
return searchService.search(q, mode, limit);
|
||||
if (limit < 1) limit = 1;
|
||||
return searchService.search(q.trim(), mode, limit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
@@ -182,7 +183,8 @@ public class VideoSseController {
|
||||
for (int i = 0; i < total; i++) {
|
||||
var v = rows.get(i);
|
||||
if (i > 0) {
|
||||
long delay = (long) (3000 + Math.random() * 5000);
|
||||
// #325 — ThreadLocalRandom으로 통일 (bulkTranscript와 일관성)
|
||||
long delay = 3000L + ThreadLocalRandom.current().nextLong(5000);
|
||||
emit(emitter, Map.of("type", "wait", "index", i, "delay", delay / 1000.0));
|
||||
Thread.sleep(delay);
|
||||
}
|
||||
@@ -347,13 +349,15 @@ public class VideoSseController {
|
||||
@PostMapping("/rebuild-vectors")
|
||||
public SseEmitter rebuildVectors() {
|
||||
AuthUtil.requireAdmin();
|
||||
SseEmitter emitter = new SseEmitter(600_000L);
|
||||
SseEmitter emitter = new SseEmitter(60_000L);
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
emit(emitter, Map.of("type", "start"));
|
||||
// TODO: Implement full vector rebuild using VectorService
|
||||
emit(emitter, Map.of("type", "complete", "total", 0));
|
||||
// #325 — 운영자에게 미구현 상태 명시 (이전: 즉시 complete(total=0) → 무반응 인상)
|
||||
emit(emitter, Map.of(
|
||||
"type", "not_implemented",
|
||||
"message", "벡터 재생성은 아직 구현되지 않았습니다. 후속 이슈(#325/#331)에서 처리 예정입니다."
|
||||
));
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
emitter.completeWithError(e);
|
||||
|
||||
@@ -10,6 +10,7 @@ import lombok.NoArgsConstructor;
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SiteVisitStats {
|
||||
private int today;
|
||||
private int total;
|
||||
// #274 — long으로 변경 (21억 이상 누적 시 int 오버플로 방지)
|
||||
private long today;
|
||||
private long total;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ public interface StatsMapper {
|
||||
|
||||
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.tasteby.domain.UserInfo;
|
||||
import com.tasteby.security.JwtTokenProvider;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -17,6 +19,8 @@ import java.util.Map;
|
||||
@Service
|
||||
public class AuthService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
|
||||
|
||||
private final UserService userService;
|
||||
private final JwtTokenProvider jwtProvider;
|
||||
private final GoogleIdTokenVerifier verifier;
|
||||
@@ -58,7 +62,10 @@ public class AuthService {
|
||||
} catch (ResponseStatusException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid Google token: " + e.getMessage());
|
||||
// #266 — 외부에는 고정 메시지만, 상세는 로그로 (Google verifier 내부 네트워크/공개키
|
||||
// 조회 실패 메시지가 클라이언트에 노출되지 않도록)
|
||||
log.warn("Google token verification failed: {}", e.getMessage());
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid Google token");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,15 @@ public class CacheService {
|
||||
this.redis = redis;
|
||||
this.mapper = mapper;
|
||||
this.ttl = Duration.ofSeconds(ttlSeconds);
|
||||
try {
|
||||
redis.getConnectionFactory().getConnection().ping();
|
||||
// #276 — ping 연결 자원 누수 방지: try-with-resources
|
||||
var factory = redis.getConnectionFactory();
|
||||
if (factory == null) {
|
||||
log.warn("Redis ConnectionFactory is null, caching disabled");
|
||||
disabled = true;
|
||||
return;
|
||||
}
|
||||
try (var conn = factory.getConnection()) {
|
||||
conn.ping();
|
||||
log.info("Redis connected");
|
||||
} catch (Exception e) {
|
||||
log.warn("Redis unavailable ({}), caching disabled", e.getMessage());
|
||||
@@ -37,6 +44,13 @@ public class CacheService {
|
||||
}
|
||||
|
||||
public String makeKey(String... parts) {
|
||||
// #276 — null/빈 파트로 "tasteby::" 같은 잘못된 키 생성 방지
|
||||
if (parts == null || parts.length == 0) {
|
||||
throw new IllegalArgumentException("makeKey requires at least one part");
|
||||
}
|
||||
for (String p : parts) {
|
||||
if (p == null) throw new IllegalArgumentException("makeKey parts must not be null");
|
||||
}
|
||||
return PREFIX + String.join(":", parts);
|
||||
}
|
||||
|
||||
@@ -85,4 +99,14 @@ public class CacheService {
|
||||
log.debug("Cache flush error: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// #290 — 단일 키 삭제 (캐시 역직렬화 실패 시 자동 evict 등에 사용)
|
||||
public void del(String key) {
|
||||
if (disabled) return;
|
||||
try {
|
||||
redis.delete(key);
|
||||
} catch (Exception e) {
|
||||
log.debug("Cache del error: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,11 +27,16 @@ public class ChannelService {
|
||||
}
|
||||
|
||||
public boolean deactivate(String channelId) {
|
||||
// Try deactivate by channel_id first, then by DB id
|
||||
int rows = mapper.deactivateByChannelId(channelId);
|
||||
if (rows == 0) {
|
||||
rows = mapper.deactivateById(channelId);
|
||||
}
|
||||
if (channelId == null || channelId.isBlank()) return false;
|
||||
// #295 — 입력 형식으로 명시적 분기:
|
||||
// "UC..."(24 chars) 형식 → YouTube channel_id로 비활성화
|
||||
// 그 외(32-char hex UUID 등) → DB id로 비활성화
|
||||
// 이전: channel_id 시도 → 0이면 id 시도. 우연히 UC가 hex와 같을 확률은 0이지만
|
||||
// 가독성/의도 명확성 + 잘못된 폴백 차단을 위해 명시화.
|
||||
boolean looksLikeYouTubeId = channelId.startsWith("UC") && channelId.length() == 24;
|
||||
int rows = looksLikeYouTubeId
|
||||
? mapper.deactivateByChannelId(channelId)
|
||||
: mapper.deactivateById(channelId);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ package com.tasteby.service;
|
||||
|
||||
import com.tasteby.domain.DaemonConfig;
|
||||
import com.tasteby.mapper.DaemonConfigMapper;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@@ -27,20 +29,33 @@ public class DaemonConfigService {
|
||||
current.setScanEnabled(Boolean.TRUE.equals(body.get("scan_enabled")));
|
||||
}
|
||||
if (body.containsKey("scan_interval_min")) {
|
||||
current.setScanIntervalMin(((Number) body.get("scan_interval_min")).intValue());
|
||||
// #275 — 0/음수 입력으로 30초 사이클 폭주 방지. ClassCastException 대신 400.
|
||||
current.setScanIntervalMin(requirePositiveInt(body.get("scan_interval_min"), "scan_interval_min"));
|
||||
}
|
||||
if (body.containsKey("process_enabled")) {
|
||||
current.setProcessEnabled(Boolean.TRUE.equals(body.get("process_enabled")));
|
||||
}
|
||||
if (body.containsKey("process_interval_min")) {
|
||||
current.setProcessIntervalMin(((Number) body.get("process_interval_min")).intValue());
|
||||
current.setProcessIntervalMin(requirePositiveInt(body.get("process_interval_min"), "process_interval_min"));
|
||||
}
|
||||
if (body.containsKey("process_limit")) {
|
||||
current.setProcessLimit(((Number) body.get("process_limit")).intValue());
|
||||
current.setProcessLimit(requirePositiveInt(body.get("process_limit"), "process_limit"));
|
||||
}
|
||||
mapper.updateConfig(current);
|
||||
}
|
||||
|
||||
/** #275 — 양의 정수 가드. 비숫자/0/음수는 400. */
|
||||
private static int requirePositiveInt(Object raw, String field) {
|
||||
if (!(raw instanceof Number n)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + "은(는) 정수여야 합니다");
|
||||
}
|
||||
int v = n.intValue();
|
||||
if (v < 1) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + "은(는) 1 이상이어야 합니다 (폭주 방지)");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
public void updateLastScan() {
|
||||
mapper.updateLastScan();
|
||||
}
|
||||
|
||||
@@ -50,8 +50,13 @@ public class DaemonScheduler {
|
||||
Instant lastScan = config.getLastScanAt() != null ? config.getLastScanAt().toInstant() : null;
|
||||
if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.getScanIntervalMin(), ChronoUnit.MINUTES))) {
|
||||
log.info("Running scheduled channel scan...");
|
||||
int newVideos = youTubeService.scanAllChannels();
|
||||
daemonConfigService.updateLastScan();
|
||||
int newVideos = 0;
|
||||
try {
|
||||
newVideos = youTubeService.scanAllChannels();
|
||||
} finally {
|
||||
// #275 — 외부 호출 예외 시에도 last_scan_at을 갱신해 다음 cron까지의 backoff를 보장
|
||||
daemonConfigService.updateLastScan();
|
||||
}
|
||||
if (newVideos > 0) {
|
||||
cacheService.flush();
|
||||
log.info("Scan completed: {} new videos", newVideos);
|
||||
@@ -63,8 +68,12 @@ public class DaemonScheduler {
|
||||
Instant lastProcess = config.getLastProcessAt() != null ? config.getLastProcessAt().toInstant() : null;
|
||||
if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.getProcessIntervalMin(), ChronoUnit.MINUTES))) {
|
||||
log.info("Running scheduled video processing (limit={})...", config.getProcessLimit());
|
||||
int restaurants = pipelineService.processPending(config.getProcessLimit());
|
||||
daemonConfigService.updateLastProcess();
|
||||
int restaurants = 0;
|
||||
try {
|
||||
restaurants = pipelineService.processPending(config.getProcessLimit());
|
||||
} finally {
|
||||
daemonConfigService.updateLastProcess();
|
||||
}
|
||||
if (restaurants > 0) {
|
||||
cacheService.flush();
|
||||
log.info("Processing completed: {} restaurants extracted", restaurants);
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.tasteby.service;
|
||||
import com.tasteby.domain.Memo;
|
||||
import com.tasteby.mapper.MemoMapper;
|
||||
import com.tasteby.util.IdGenerator;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -25,11 +26,18 @@ public class MemoService {
|
||||
@Transactional
|
||||
public Memo upsert(String userId, String restaurantId, Double rating, String memoText, LocalDate visitedAt) {
|
||||
String visitedStr = visitedAt != null ? visitedAt.toString() : null;
|
||||
// #294 — 동시성 가드: 사전 SELECT → 분기 INSERT/UPDATE 패턴은 두 트랜잭션이 동시에 미존재
|
||||
// 판정 후 둘 다 INSERT → UNIQUE 충돌(500). INSERT 우선 시도 후 DuplicateKeyException 시 UPDATE.
|
||||
Memo existing = mapper.findByUserAndRestaurant(userId, restaurantId);
|
||||
if (existing != null) {
|
||||
mapper.updateMemo(userId, restaurantId, rating, memoText, visitedStr);
|
||||
} else {
|
||||
mapper.insertMemo(IdGenerator.newId(), userId, restaurantId, rating, memoText, visitedStr);
|
||||
try {
|
||||
mapper.insertMemo(IdGenerator.newId(), userId, restaurantId, rating, memoText, visitedStr);
|
||||
} catch (DuplicateKeyException e) {
|
||||
// 동시 INSERT 충돌 → UPDATE로 폴백
|
||||
mapper.updateMemo(userId, restaurantId, rating, memoText, visitedStr);
|
||||
}
|
||||
}
|
||||
return mapper.findByUserAndRestaurant(userId, restaurantId);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.tasteby.domain.Review;
|
||||
import com.tasteby.mapper.ReviewMapper;
|
||||
import com.tasteby.util.IdGenerator;
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -60,10 +61,15 @@ public class ReviewService {
|
||||
if (existingId != null) {
|
||||
mapper.deleteFavorite(userId, restaurantId);
|
||||
return false;
|
||||
} else {
|
||||
mapper.insertFavorite(IdGenerator.newId(), userId, restaurantId);
|
||||
return true;
|
||||
}
|
||||
// #294 — 동시성 가드: 동시 INSERT 시 UNIQUE 충돌 → 한 쪽 500.
|
||||
// INSERT 시도 후 DuplicateKeyException은 "이미 추가됨"으로 간주 (토글 의도는 ON).
|
||||
try {
|
||||
mapper.insertFavorite(IdGenerator.newId(), userId, restaurantId);
|
||||
} catch (DuplicateKeyException ignored) {
|
||||
// 다른 트랜잭션이 먼저 INSERT 함 — 결과는 어쨌든 즐겨찾기 ON.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public List<Restaurant> getUserFavorites(String userId) {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.mapper.SearchMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
@@ -12,12 +15,17 @@ import java.util.*;
|
||||
public class SearchService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SearchService.class);
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final TypeReference<List<Restaurant>> LIST_TYPE = new TypeReference<>() {};
|
||||
|
||||
private final SearchMapper searchMapper;
|
||||
private final RestaurantService restaurantService;
|
||||
private final VectorService vectorService;
|
||||
private final CacheService cache;
|
||||
|
||||
@Value("${app.search.max-distance:0.57}")
|
||||
private double maxDistance;
|
||||
|
||||
public SearchService(SearchMapper searchMapper,
|
||||
RestaurantService restaurantService,
|
||||
VectorService vectorService,
|
||||
@@ -33,8 +41,8 @@ public class SearchService {
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
return mapper.readValue(cached, new com.fasterxml.jackson.core.type.TypeReference<List<Restaurant>>() {});
|
||||
// #293 — ObjectMapper 재사용 (필드 static)
|
||||
return JSON.readValue(cached, LIST_TYPE);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
@@ -44,13 +52,20 @@ public class SearchService {
|
||||
case "hybrid" -> {
|
||||
var kw = keywordSearch(q, limit);
|
||||
var sem = semanticSearch(q, limit);
|
||||
// #293 — semantic 결과에도 channels 부착 (이전: keyword에만 부착되어 hybrid에서 sem 결과는 채널 누락)
|
||||
if (!sem.isEmpty()) attachChannels(sem);
|
||||
Set<String> seen = new HashSet<>();
|
||||
var merged = new ArrayList<Restaurant>();
|
||||
for (var r : kw) { if (seen.add(r.getId())) merged.add(r); }
|
||||
for (var r : sem) { if (seen.add(r.getId())) merged.add(r); }
|
||||
result = merged.size() > limit ? merged.subList(0, limit) : merged;
|
||||
}
|
||||
default -> result = keywordSearch(q, limit);
|
||||
case "keyword" -> result = keywordSearch(q, limit);
|
||||
default -> {
|
||||
// #293 — 알 수 없는 mode는 silent fallback 대신 경고 로그
|
||||
log.warn("Unknown search mode '{}', falling back to keyword", mode);
|
||||
result = keywordSearch(q, limit);
|
||||
}
|
||||
}
|
||||
|
||||
cache.set(key, result);
|
||||
@@ -58,7 +73,10 @@ public class SearchService {
|
||||
}
|
||||
|
||||
private List<Restaurant> keywordSearch(String q, int limit) {
|
||||
String pattern = "%" + q + "%";
|
||||
// #293 — LIKE 와일드카드 escape: 사용자 입력의 %, _, \ 를 리터럴로 처리.
|
||||
// SQL에서는 ESCAPE '\\' 절을 사용 (SearchMapper.xml).
|
||||
String escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
|
||||
String pattern = "%" + escaped + "%";
|
||||
List<Restaurant> results = searchMapper.keywordSearch(pattern, limit);
|
||||
if (!results.isEmpty()) {
|
||||
attachChannels(results);
|
||||
@@ -68,7 +86,7 @@ public class SearchService {
|
||||
|
||||
private List<Restaurant> semanticSearch(String q, int limit) {
|
||||
try {
|
||||
var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), 0.57);
|
||||
var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), maxDistance);
|
||||
if (similar.isEmpty()) return List.of();
|
||||
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
|
||||
@@ -2,11 +2,16 @@ package com.tasteby.service;
|
||||
|
||||
import com.tasteby.domain.SiteVisitStats;
|
||||
import com.tasteby.mapper.StatsMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class StatsService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(StatsService.class);
|
||||
|
||||
private final StatsMapper mapper;
|
||||
|
||||
public StatsService(StatsMapper mapper) {
|
||||
@@ -14,7 +19,19 @@ public class StatsService {
|
||||
}
|
||||
|
||||
public void recordVisit() {
|
||||
mapper.recordVisit();
|
||||
// #274 — 자정 경계 동시성: 두 트랜잭션이 동시에 'NOT MATCHED' 판정 → 둘 다 INSERT
|
||||
// → PK/UNIQUE 충돌 시 한 쪽 500. 1회 재시도(다음엔 MATCHED → UPDATE 분기).
|
||||
try {
|
||||
mapper.recordVisit();
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
log.debug("recordVisit conflict (midnight race), retry once: {}", e.getMessage());
|
||||
try {
|
||||
mapper.recordVisit();
|
||||
} catch (DataIntegrityViolationException retryFail) {
|
||||
// 두 번째 시도도 실패: 카운트 1건 손실은 수용 (운영 영향 미미)
|
||||
log.warn("recordVisit double-conflict, dropping one count: {}", retryFail.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SiteVisitStats getVisits() {
|
||||
|
||||
@@ -27,12 +27,15 @@ public class VectorService {
|
||||
*/
|
||||
public List<Map<String, Object>> searchSimilar(String query, int topK, double maxDistance) {
|
||||
List<List<Double>> embeddings = genAi.embedTexts(List.of(query));
|
||||
if (embeddings.isEmpty()) return List.of();
|
||||
// #293 — embeddings 빈/null 가드 (NPE/IndexOutOfBoundsException 방지)
|
||||
if (embeddings == null || embeddings.isEmpty()) return List.of();
|
||||
List<Double> first = embeddings.getFirst();
|
||||
if (first == null || first.isEmpty()) return List.of();
|
||||
|
||||
// Convert to float array for Oracle VECTOR type
|
||||
float[] queryVec = new float[embeddings.getFirst().size()];
|
||||
float[] queryVec = new float[first.size()];
|
||||
for (int i = 0; i < queryVec.length; i++) {
|
||||
queryVec[i] = embeddings.getFirst().get(i).floatValue();
|
||||
queryVec[i] = first.get(i).floatValue();
|
||||
}
|
||||
|
||||
String sql = """
|
||||
|
||||
@@ -278,8 +278,19 @@ public class YouTubeService {
|
||||
|
||||
/**
|
||||
* Fetch transcript for a YouTube video.
|
||||
* Tries API first (fast), then falls back to Playwright browser extraction.
|
||||
* @param mode "auto" = manual first then generated, "manual" = manual only, "generated" = generated only
|
||||
*
|
||||
* 흐름: (1) Playwright headed 브라우저 추출 → (2) 실패 시 youtube-transcript-api 폴백.
|
||||
*
|
||||
* <p>#325 — mode 인자 명세:
|
||||
* <ul>
|
||||
* <li>"auto" (기본): manual → generated 순서로 시도</li>
|
||||
* <li>"manual": manual(사람이 쓴 자막)만</li>
|
||||
* <li>"generated": 자동 생성 자막만</li>
|
||||
* </ul>
|
||||
* 주의: mode 인자는 <b>youtube-transcript-api 폴백 경로에서만 사용</b>됩니다.
|
||||
* 브라우저 추출은 YouTube가 노출하는 자막 트랙 전체를 그대로 수신하므로 mode 무관.
|
||||
*
|
||||
* @param mode 위 설명 참조. null이면 "auto"로 간주.
|
||||
*/
|
||||
public TranscriptResult getTranscript(String videoId, String mode) {
|
||||
if (mode == null) mode = "auto";
|
||||
|
||||
@@ -59,6 +59,11 @@ app:
|
||||
cache:
|
||||
ttl-seconds: 600
|
||||
|
||||
search:
|
||||
# #293 — 벡터 검색 cosine distance 임계값 (0.0=완전일치, 1.0=직교).
|
||||
# 0.57은 cohere embed-v4 한국어 시맨틱 적합도 기준 경험값.
|
||||
max-distance: ${SEARCH_MAX_DISTANCE:0.57}
|
||||
|
||||
daemon:
|
||||
# 인스턴스 차원 스케줄러 활성화. dev/prod가 같은 DB를 공유하므로
|
||||
# dev .env에 DAEMON_ENABLED=false를 설정해 dev 폴링을 끄고 prod만 동작시킨다.
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
</update>
|
||||
|
||||
<select id="findByChannelId" resultMap="channelResultMap">
|
||||
SELECT id, channel_id, channel_name, title_filter
|
||||
<!-- #295 — findAllActive와 동일하게 description/tags/sort_order까지 SELECT -->
|
||||
SELECT id, channel_id, channel_name, title_filter, description, tags, sort_order
|
||||
FROM channels
|
||||
WHERE channel_id = #{channelId} AND is_active = 1
|
||||
</select>
|
||||
|
||||
@@ -79,7 +79,8 @@
|
||||
</select>
|
||||
|
||||
<select id="getAvgRating" resultType="map">
|
||||
SELECT ROUND(AVG(rating), 1) AS avg_rating, COUNT(*) AS review_count
|
||||
<!-- #294 — review 0건이면 AVG는 NULL → 클라이언트 NaN 처리 부담. NVL로 0.0 보장. -->
|
||||
SELECT NVL(ROUND(AVG(rating), 1), 0) AS avg_rating, COUNT(*) AS review_count
|
||||
FROM user_reviews
|
||||
WHERE restaurant_id = #{restaurantId}
|
||||
</select>
|
||||
|
||||
@@ -30,12 +30,13 @@
|
||||
JOIN video_restaurants vr ON vr.restaurant_id = r.id
|
||||
JOIN videos v ON v.id = vr.video_id
|
||||
WHERE r.latitude IS NOT NULL
|
||||
AND (UPPER(r.name) LIKE UPPER(#{query})
|
||||
OR UPPER(r.address) LIKE UPPER(#{query})
|
||||
OR UPPER(r.region) LIKE UPPER(#{query})
|
||||
OR UPPER(r.cuisine_type) LIKE UPPER(#{query})
|
||||
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query})
|
||||
OR UPPER(v.title) LIKE UPPER(#{query}))
|
||||
<!-- #293 — ESCAPE 절로 사용자 입력의 %, _ 와일드카드 의도 우회 차단 -->
|
||||
AND (UPPER(r.name) LIKE UPPER(#{query}) ESCAPE '\'
|
||||
OR UPPER(r.address) LIKE UPPER(#{query}) ESCAPE '\'
|
||||
OR UPPER(r.region) LIKE UPPER(#{query}) ESCAPE '\'
|
||||
OR UPPER(r.cuisine_type) LIKE UPPER(#{query}) ESCAPE '\'
|
||||
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query}) ESCAPE '\'
|
||||
OR UPPER(v.title) LIKE UPPER(#{query}) ESCAPE '\')
|
||||
FETCH FIRST #{limit} ROWS ONLY
|
||||
</select>
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<!-- 함수 설계서. 작성: [AI] Architect. -->
|
||||
|
||||
# 함수 설계서: BottomSheet snap 정책 (#319)
|
||||
|
||||
> **상태**: Approved <!-- Draft | Approved | Superseded -->
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||
> **추적성** — Redmine: #319 / 부모 #301 · 관련 컴포넌트: `frontend/src/components/BottomSheet.tsx`
|
||||
|
||||
## 1. 책임
|
||||
|
||||
모바일 BottomSheet의 **3-snap 점착(snap) 점**과 **닫힘 임계 속도**를 정의하고, 사용자의 드래그 제스처가 어느 snap에 안착할지 결정하는 알고리즘을 명세한다.
|
||||
|
||||
## 2. 상수 (매직 넘버)
|
||||
|
||||
| 상수 | 값 | 의미 |
|
||||
|------|----|------|
|
||||
| `SNAP_POINTS.PEEK` | `0.4` | 초기/맨 아래 snap. 화면 높이의 40%만 시트가 보임 — 지도와 시트를 함께 보는 균형. |
|
||||
| `SNAP_POINTS.HALF` | `0.55` | 중간 snap. 시트 콘텐츠 핵심(이름·평점·리뷰 첫 줄)이 잘 보이는 위치. |
|
||||
| `SNAP_POINTS.FULL` | `0.92` | 거의 풀 화면. 8% 여백은 상단 스와이프 핸들·상태바를 위해 남김. |
|
||||
| `VELOCITY_THRESHOLD` | `0.5` (vh/s) | 빠른 아래 스와이프 감지 기준. 초당 화면 높이의 50% 이상이면 "닫기 의도"로 간주. |
|
||||
| `CLOSE_BELOW_RATIO` | `0.6 × PEEK` ≈ 0.24 | snap 후보 중 PEEK의 60% 아래로 끌어내리면 강제 닫힘. |
|
||||
|
||||
## 3. 결정 알고리즘 (`snapTo(height, velocity)`)
|
||||
|
||||
```
|
||||
입력: height(현재 높이 ratio 0~1), velocity(아래 방향 vh/s, 양수=아래)
|
||||
|
||||
1. velocity > VELOCITY_THRESHOLD && height < HALF → onClose()
|
||||
2. height < CLOSE_BELOW_RATIO → onClose()
|
||||
3. 그 외: [PEEK, HALF, FULL] 중 height와 최단 거리인 점에 setHeight()
|
||||
```
|
||||
|
||||
## 4. 왜 이 값들인가 (근거)
|
||||
|
||||
- **0.4/0.55/0.92**: 모바일 UX 가이드(Material/iOS BottomSheet)의 30%/50%/95% 패턴을 한국 식당 카드 콘텐츠 길이(이름 18px + 평점 16px + 영상 썸네일 200px 기준)에 맞춰 조정.
|
||||
- **0.5 vh/s**: 일반 손가락 플릭 속도가 0.7~1.2 vh/s, 의도적인 닫기 스와이프 임계점.
|
||||
- **0.24 close-below**: PEEK(0.4)의 60% — 우발적 드래그(<0.05) 차단 + 의도적 닫기(<0.24) 수용.
|
||||
|
||||
## 5. 변경 시 주의
|
||||
|
||||
- 사용자의 근육 기억(스와이프 거리 감각) 교란 위험이 있어 SNAP_POINTS 변경은 ADR로 분리할 것.
|
||||
- VELOCITY_THRESHOLD를 낮추면 의도치 않은 닫힘 ↑, 높이면 닫기 어려움 ↑.
|
||||
|
||||
## 6. 테스트 권고
|
||||
|
||||
- `snapTo(0.45, 0.1)` → HALF로 안착
|
||||
- `snapTo(0.2, 0.7)` → onClose 호출
|
||||
- `snapTo(0.85, 0)` → FULL로 안착
|
||||
- `snapTo(0.1, 0)` → onClose 호출 (CLOSE_BELOW_RATIO)
|
||||
- 단위 테스트는 `utils/bottomSheetSnap.ts`로 함수 추출 후 #343에서 진행.
|
||||
|
||||
## 7. 미해결 질문
|
||||
|
||||
- 가로 모드 / 큰 폰트 접근성 모드에서 PEEK가 너무 작아 보이는 케이스 — 향후 동적 조정 검토.
|
||||
@@ -1470,12 +1470,12 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
setEditRest({
|
||||
name: r.name,
|
||||
cuisine_type: r.cuisine_type || "",
|
||||
foods_mentioned: r.foods_mentioned.join(", "),
|
||||
foods_mentioned: (r.foods_mentioned || []).join(", "),
|
||||
evaluation: evalText,
|
||||
address: r.address || "",
|
||||
region: r.region || "",
|
||||
price_range: r.price_range || "",
|
||||
guests: r.guests.join(", "),
|
||||
guests: (r.guests || []).join(", "),
|
||||
});
|
||||
} : undefined}
|
||||
title={isAdmin ? "클릭하여 수정" : undefined}
|
||||
@@ -1513,7 +1513,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
{r.cuisine_type && <p>종류: {r.cuisine_type}</p>}
|
||||
{r.price_range && <p>가격대: {r.price_range}</p>}
|
||||
</div>
|
||||
{r.foods_mentioned.length > 0 && (
|
||||
{r.foods_mentioned?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{r.foods_mentioned.map((f, j) => (
|
||||
<span key={j} className="px-1.5 py-0.5 bg-brand-50 text-brand-700 rounded text-xs">{f}</span>
|
||||
@@ -1523,7 +1523,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
{r.evaluation?.text && (
|
||||
<p className="mt-1 text-xs text-gray-600">{r.evaluation.text}</p>
|
||||
)}
|
||||
{r.guests.length > 0 && (
|
||||
{r.guests?.length > 0 && (
|
||||
<p className="mt-1 text-xs text-gray-500">게스트: {r.guests.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1627,6 +1627,45 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// #322/#323 LLM 검증 UI
|
||||
const [verifyPending, setVerifyPending] = useState<number | null>(null);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [verifyResult, setVerifyResult] = useState<string | null>(null);
|
||||
|
||||
const loadVerifyPending = useCallback(() => {
|
||||
api.getVerifyPending().then((r) => setVerifyPending(r.pending)).catch(() => setVerifyPending(null));
|
||||
}, []);
|
||||
useEffect(() => { loadVerifyPending(); }, [loadVerifyPending]);
|
||||
|
||||
const handleVerifyAll = async () => {
|
||||
if (!isAdmin) return;
|
||||
if (!confirm(`미검증 식당 ${verifyPending ?? "?"}건을 LLM으로 일괄 검증합니다.\n잘못 등록된 데이터/프랜차이즈를 자동으로 숨김 처리합니다.\n진행할까요?`)) return;
|
||||
setVerifying(true);
|
||||
setVerifyResult(null);
|
||||
try {
|
||||
const r = await api.verifyAll(10);
|
||||
setVerifyResult(`${r.processed}건 검증 완료`);
|
||||
loadVerifyPending();
|
||||
load();
|
||||
} catch (e) {
|
||||
setVerifyResult(`실패: ${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleHidden = async (r: Restaurant) => {
|
||||
if (!isAdmin) return;
|
||||
const becomingHidden = !r.hidden;
|
||||
const reason = becomingHidden ? (prompt("숨김 사유(선택)", "manual") ?? "manual") : "";
|
||||
try {
|
||||
await api.setRestaurantHidden(r.id, becomingHidden, reason || "manual");
|
||||
load();
|
||||
} catch (e) {
|
||||
alert(`실패: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = restaurants.filter((r) => {
|
||||
if (nameSearch && !r.name.toLowerCase().includes(nameSearch.toLowerCase())) return false;
|
||||
return true;
|
||||
@@ -1728,6 +1767,18 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (<>
|
||||
{/* #322/#323 — LLM 검증 */}
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>미검증 {verifyPending ?? "?"}건</span>
|
||||
<button
|
||||
onClick={handleVerifyAll}
|
||||
disabled={verifying || verifyPending === 0}
|
||||
className="px-3 py-1 text-xs rounded bg-amber-500 hover:bg-amber-600 text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{verifying ? "검증 중..." : "LLM 검증"}
|
||||
</button>
|
||||
{verifyResult && <span className="text-amber-600">{verifyResult}</span>}
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const pending = await fetch(`/api/restaurants/tabling-pending`, {
|
||||
@@ -1890,6 +1941,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("price_range")}>가격대{sortIcon("price_range")}</th>
|
||||
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("rating")}>평점{sortIcon("rating")}</th>
|
||||
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("business_status")}>상태{sortIcon("business_status")}</th>
|
||||
<th className="text-center px-4 py-3">검증</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1917,11 +1969,34 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<span className="text-xs text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center" onClick={(e) => e.stopPropagation()}>
|
||||
{r.hidden ? (
|
||||
<button
|
||||
onClick={() => handleToggleHidden(r)}
|
||||
disabled={!isAdmin}
|
||||
title={r.hidden_reason || "manual"}
|
||||
className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold hover:bg-red-200 disabled:opacity-50"
|
||||
>
|
||||
숨김 {r.hidden_reason ? `(${r.hidden_reason.slice(0, 12)})` : ""}
|
||||
</button>
|
||||
) : r.verified_at ? (
|
||||
<button
|
||||
onClick={() => handleToggleHidden(r)}
|
||||
disabled={!isAdmin}
|
||||
title="검증 통과 — 클릭하면 숨김"
|
||||
className="px-1.5 py-0.5 bg-green-100 text-green-700 rounded text-[10px] font-semibold hover:bg-green-200 disabled:opacity-50"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">미검증</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||
식당 데이터가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
@@ -2144,6 +2219,7 @@ interface AdminUser {
|
||||
email: string | null;
|
||||
nickname: string | null;
|
||||
avatar_url: string | null;
|
||||
is_admin: boolean;
|
||||
provider: string | null;
|
||||
created_at: string | null;
|
||||
favorite_count: number;
|
||||
@@ -2246,6 +2322,7 @@ function UsersPanel() {
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">사용자</th>
|
||||
<th className="text-left px-4 py-2">이메일</th>
|
||||
<th className="text-center px-4 py-2">관리자</th>
|
||||
<th className="text-center px-4 py-2">찜</th>
|
||||
<th className="text-center px-4 py-2">리뷰</th>
|
||||
<th className="text-center px-4 py-2">메모</th>
|
||||
@@ -2282,6 +2359,27 @@ function UsersPanel() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">{u.email || "-"}</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.updateAdminUserAdmin(u.id, !u.is_admin);
|
||||
setUsers((prev) => prev.map((x) => x.id === u.id ? { ...x, is_admin: !u.is_admin } : x));
|
||||
} catch (err) {
|
||||
console.error("Failed to update admin:", err);
|
||||
alert("관리자 권한 변경에 실패했습니다.");
|
||||
}
|
||||
}}
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium transition-colors ${
|
||||
u.is_admin
|
||||
? "bg-green-100 text-green-700 hover:bg-green-200"
|
||||
: "bg-gray-100 text-gray-400 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{u.is_admin ? "ON" : "OFF"}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{u.favorite_count > 0 ? (
|
||||
<span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 rounded-full text-xs font-medium">
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
--brand-800: #9A4500;
|
||||
--brand-900: #6B3000;
|
||||
--brand-950: #3D1A00;
|
||||
/* #344 z-index 토큰 (모달/오버레이가 매직 넘버 없이 일관) */
|
||||
--z-bottom-sheet: 50;
|
||||
--z-filter-sheet: 60;
|
||||
--z-modal: 70;
|
||||
color-scheme: only light !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { GoogleLogin } from "@react-oauth/google";
|
||||
import { useEscapeKey, useFocusTrap, useBodyScrollLock } from "@/lib/hooks/useModalA11y";
|
||||
|
||||
interface LoginMenuProps {
|
||||
onGoogleSuccess: (credential: string) => void;
|
||||
@@ -10,6 +11,22 @@ interface LoginMenuProps {
|
||||
|
||||
export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = "login-dialog-title";
|
||||
|
||||
// #283 — 모달 접근성: ESC / focus trap / body scroll lock
|
||||
useEscapeKey(open, () => setOpen(false));
|
||||
useFocusTrap(open, dialogRef);
|
||||
useBodyScrollLock(open);
|
||||
|
||||
const handleSuccess = (res: { credential?: string }) => {
|
||||
setErrorMsg(null);
|
||||
if (res.credential) {
|
||||
onGoogleSuccess(res.credential);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -22,30 +39,40 @@ export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) {
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
// #344 — z-index 매직 넘버 99999 → CSS 변수 토큰 (--z-modal=70).
|
||||
// 다른 오버레이(BottomSheet=50, FilterSheet=60) 위 일관된 stacking.
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
style={{ zIndex: 99999 }}
|
||||
style={{ zIndex: "var(--z-modal)" } as React.CSSProperties}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setOpen(false); }}
|
||||
>
|
||||
<div className="bg-surface rounded-2xl shadow-2xl p-6 mx-4 w-full max-w-xs space-y-4">
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
className="bg-surface rounded-2xl shadow-2xl p-6 mx-4 w-full max-w-sm space-y-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold dark:text-gray-100">로그인</h3>
|
||||
<h3 id={titleId} className="text-base font-semibold dark:text-gray-100">로그인</h3>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-lg leading-none"
|
||||
aria-label="로그인 창 닫기"
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-lg leading-none p-2 -m-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">소셜 계정으로 간편 로그인</p>
|
||||
{errorMsg && (
|
||||
<p role="alert" className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/40 rounded p-2">
|
||||
{errorMsg}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<GoogleLogin
|
||||
onSuccess={(res) => {
|
||||
if (res.credential) {
|
||||
onGoogleSuccess(res.credential);
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
onError={() => console.error("Google login failed")}
|
||||
onSuccess={handleSuccess}
|
||||
onError={() => setErrorMsg("Google 로그인에 실패했습니다. 팝업 차단 또는 네트워크 상태를 확인해주세요.")}
|
||||
size="large"
|
||||
width="260"
|
||||
text="signin_with"
|
||||
|
||||
@@ -67,8 +67,7 @@ type RestaurantProps = { restaurant: Restaurant };
|
||||
type RestaurantFeature = Supercluster.PointFeature<RestaurantProps>;
|
||||
|
||||
function useSupercluster(restaurants: Restaurant[]) {
|
||||
const indexRef = useRef<Supercluster<{ restaurant: Restaurant }> | null>(null);
|
||||
|
||||
// #278 — indexRef 제거 (set만 되고 read 없는 dead code)
|
||||
const points: RestaurantFeature[] = useMemo(
|
||||
() =>
|
||||
restaurants.map((r) => ({
|
||||
@@ -86,7 +85,6 @@ function useSupercluster(restaurants: Restaurant[]) {
|
||||
minPoints: 2,
|
||||
});
|
||||
sc.load(points);
|
||||
indexRef.current = sc;
|
||||
return sc;
|
||||
}, [points]);
|
||||
|
||||
@@ -129,12 +127,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
|
||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
|
||||
|
||||
// Build a lookup for restaurants by id
|
||||
const restaurantMap = useMemo(() => {
|
||||
const m: Record<string, Restaurant> = {};
|
||||
restaurants.forEach((r) => { m[r.id] = r; });
|
||||
return m;
|
||||
}, [restaurants]);
|
||||
// #278 — restaurantMap 제거 (빌드만 되고 렌더에서 사용 안 됨, dead code)
|
||||
|
||||
const clusters = useMemo(() => {
|
||||
if (!bounds) return [];
|
||||
@@ -273,7 +266,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
|
||||
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}
|
||||
</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 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" && (
|
||||
<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);
|
||||
}, [onBoundsChanged]);
|
||||
|
||||
// #278 — 언마운트 시 디바운스 타이머 정리 (메모리 누수 + unmounted setState 경고 방지)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<APIProvider apiKey={API_KEY}>
|
||||
<Map
|
||||
@@ -380,10 +380,12 @@ export default function MapView({ restaurants, selected, onSelectRestaurant, onB
|
||||
{onMyLocation && (
|
||||
<button
|
||||
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="내 위치"
|
||||
>
|
||||
<Icon name="my_location" size={20} />
|
||||
<Icon name="my_location" size={22} />
|
||||
</button>
|
||||
)}
|
||||
{channelNames.length > 0 && (
|
||||
|
||||
@@ -5,11 +5,13 @@ import { api } from "@/lib/api";
|
||||
import type { Memo } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import Icon from "@/components/Icon";
|
||||
import Stars from "@/components/Stars";
|
||||
|
||||
interface MemoSectionProps {
|
||||
restaurantId: string;
|
||||
}
|
||||
|
||||
// #281 — ReviewSection의 StarSelector와 동일 UX (0.5 단위 + 44px 터치 + ARIA radiogroup)
|
||||
function StarSelector({
|
||||
value,
|
||||
onChange,
|
||||
@@ -18,38 +20,32 @@ function StarSelector({
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div role="radiogroup" aria-label="별점 선택" className="flex items-center gap-0.5">
|
||||
<span className="text-xs text-gray-500 mr-1">별점:</span>
|
||||
{[0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5].map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => onChange(v)}
|
||||
className={`w-6 h-6 text-xs rounded border ${
|
||||
value === v
|
||||
? "bg-yellow-500 text-white border-yellow-600"
|
||||
: "bg-white text-gray-600 border-gray-300 hover:border-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
{[1, 2, 3, 4, 5].map((v) => {
|
||||
const nextVal = value === v ? v - 0.5 : v;
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value >= v - 0.5 && value <= v}
|
||||
aria-label={`${nextVal}점`}
|
||||
onClick={() => onChange(nextVal)}
|
||||
className="min-w-[44px] min-h-[44px] flex items-center justify-center touch-manipulation"
|
||||
title={`${nextVal}점`}
|
||||
>
|
||||
<span className={`text-xl ${v <= value ? "text-yellow-500" : v - 0.5 === value ? "text-yellow-400" : "text-gray-300"}`}>
|
||||
{v <= value ? "★" : v - 0.5 === value ? "⯨" : "☆"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{value > 0 && <span className="text-xs text-yellow-600 font-medium ml-1">{value}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StarDisplay({ rating }: { rating: number }) {
|
||||
const stars = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
stars.push(
|
||||
<span key={i} className={rating >= i - 0.5 ? "text-yellow-500" : "text-gray-300"}>
|
||||
★
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="text-sm">{stars}</span>;
|
||||
}
|
||||
|
||||
export default function MemoSection({ restaurantId }: MemoSectionProps) {
|
||||
const { user } = useAuth();
|
||||
const [memo, setMemo] = useState<Memo | null>(null);
|
||||
@@ -104,6 +100,9 @@ export default function MemoSection({ restaurantId }: MemoSectionProps) {
|
||||
setMemo(saved);
|
||||
setShowForm(false);
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
// #281 — 사용자 피드백
|
||||
alert(`메모 저장 실패: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -111,8 +110,12 @@ export default function MemoSection({ restaurantId }: MemoSectionProps) {
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm("메모를 삭제하시겠습니까?")) return;
|
||||
await api.deleteMemo(restaurantId);
|
||||
setMemo(null);
|
||||
try {
|
||||
await api.deleteMemo(restaurantId);
|
||||
setMemo(null);
|
||||
} catch (err) {
|
||||
alert(`메모 삭제 실패: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -167,7 +170,7 @@ export default function MemoSection({ restaurantId }: MemoSectionProps) {
|
||||
) : memo ? (
|
||||
<div className="border border-brand-200 rounded-lg p-3 bg-brand-50/30">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{memo.rating && <StarDisplay rating={memo.rating} />}
|
||||
{memo.rating && <Stars rating={memo.rating} />}
|
||||
{memo.visited_at && (
|
||||
<span className="text-xs text-gray-400">방문일: {memo.visited_at}</span>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import type { Review, Memo } from "@/lib/api";
|
||||
import Icon from "@/components/Icon";
|
||||
import Stars from "@/components/Stars";
|
||||
|
||||
interface MyReview extends Review {
|
||||
restaurant_id: string;
|
||||
@@ -82,9 +83,9 @@ export default function MyReviewsList({
|
||||
<span className="font-semibold text-sm truncate">
|
||||
{r.restaurant_name || "알 수 없는 식당"}
|
||||
</span>
|
||||
<span className="text-yellow-500 text-sm shrink-0 ml-2">
|
||||
{"★".repeat(Math.round(r.rating))}
|
||||
<span className="text-gray-500 ml-1">{r.rating}</span>
|
||||
<span className="text-sm shrink-0 ml-2 flex items-center gap-1">
|
||||
<Stars rating={r.rating} />
|
||||
<span className="text-gray-500">{r.rating}</span>
|
||||
</span>
|
||||
</div>
|
||||
{r.review_text && (
|
||||
@@ -118,9 +119,9 @@ export default function MyReviewsList({
|
||||
{m.restaurant_name || "알 수 없는 식당"}
|
||||
</span>
|
||||
{m.rating && (
|
||||
<span className="text-yellow-500 text-sm shrink-0 ml-2">
|
||||
{"★".repeat(Math.round(m.rating))}
|
||||
<span className="text-gray-500 ml-1">{m.rating}</span>
|
||||
<span className="text-sm shrink-0 ml-2 flex items-center gap-1">
|
||||
<Stars rating={m.rating} />
|
||||
<span className="text-gray-500">{m.rating}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,17 @@ interface RestaurantDetailProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// #319 — 외부 지도 검색용 쿼리 빌더. region이 더미('나라|' 형태)면 무시.
|
||||
function buildSearchQuery(r: Restaurant): string {
|
||||
if (r.address) return `${r.name} ${r.address}`;
|
||||
if (r.region) {
|
||||
const cleanRegion = r.region.replace(/\|/g, " ").trim();
|
||||
// 빈 토큰만 남는 경우 (예: '한국' 또는 '한국|') → name만 사용
|
||||
if (cleanRegion && cleanRegion !== "한국") return `${r.name} ${cleanRegion}`;
|
||||
}
|
||||
return r.name;
|
||||
}
|
||||
|
||||
export default function RestaurantDetail({
|
||||
restaurant,
|
||||
onClose,
|
||||
@@ -128,7 +139,7 @@ export default function RestaurantDetail({
|
||||
{restaurant.google_place_id && (
|
||||
<p className="flex gap-3">
|
||||
<a
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name + (restaurant.address ? " " + restaurant.address : restaurant.region ? " " + restaurant.region.replace(/\|/g, " ") : ""))}`}
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(buildSearchQuery(restaurant))}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-600 dark:text-brand-400 hover:underline text-xs"
|
||||
|
||||
@@ -4,37 +4,12 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Review } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import Stars from "@/components/Stars";
|
||||
|
||||
interface ReviewSectionProps {
|
||||
restaurantId: string;
|
||||
}
|
||||
|
||||
function StarDisplay({ rating }: { rating: number }) {
|
||||
const stars = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
if (rating >= i) {
|
||||
stars.push(
|
||||
<span key={i} className="text-yellow-500">
|
||||
★
|
||||
</span>
|
||||
);
|
||||
} else if (rating >= i - 0.5) {
|
||||
stars.push(
|
||||
<span key={i} className="text-yellow-500">
|
||||
★
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
stars.push(
|
||||
<span key={i} className="text-gray-300">
|
||||
★
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
return <span className="text-sm">{stars}</span>;
|
||||
}
|
||||
|
||||
function StarSelector({
|
||||
value,
|
||||
onChange,
|
||||
@@ -43,22 +18,30 @@ function StarSelector({
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div role="radiogroup" aria-label="별점 선택" className="flex items-center gap-0.5">
|
||||
<span className="text-xs text-gray-500 mr-1">별점:</span>
|
||||
{[0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5].map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => onChange(v)}
|
||||
className={`w-6 h-6 text-xs rounded border ${
|
||||
value === v
|
||||
? "bg-yellow-500 text-white border-yellow-600"
|
||||
: "bg-white text-gray-600 border-gray-300 hover:border-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
{[1, 2, 3, 4, 5].map((v) => {
|
||||
const isCurrent = value === v || value === v - 0.5;
|
||||
const nextVal = value === v ? v - 0.5 : v;
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value >= v - 0.5 && value <= v}
|
||||
aria-label={`${nextVal}점`}
|
||||
onClick={() => onChange(nextVal)}
|
||||
// #281 — 최소 터치 영역 44×44
|
||||
className="min-w-[44px] min-h-[44px] flex items-center justify-center touch-manipulation"
|
||||
title={`${nextVal}점`}
|
||||
>
|
||||
<span className={`text-xl ${v <= value ? "text-yellow-500" : v - 0.5 === value ? "text-yellow-400" : "text-gray-300"}`}>
|
||||
{v <= value ? "★" : v - 0.5 === value ? "⯨" : "☆"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{value > 0 && <span className="text-xs text-yellow-600 font-medium ml-1">{value}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -170,29 +153,42 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
|
||||
? reviews.find((r) => r.user_id === user.id)
|
||||
: null;
|
||||
|
||||
// #281 — API 실패 시 unhandled rejection 방지 + 사용자 피드백
|
||||
const handleCreate = async (data: {
|
||||
rating: number;
|
||||
review_text?: string;
|
||||
visited_at?: string;
|
||||
}) => {
|
||||
await api.createReview(restaurantId, data);
|
||||
setShowForm(false);
|
||||
loadReviews();
|
||||
try {
|
||||
await api.createReview(restaurantId, data);
|
||||
setShowForm(false);
|
||||
loadReviews();
|
||||
} catch (e) {
|
||||
alert(`리뷰 작성 실패: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (
|
||||
reviewId: string,
|
||||
data: { rating: number; review_text?: string; visited_at?: string }
|
||||
) => {
|
||||
await api.updateReview(reviewId, data);
|
||||
setEditingId(null);
|
||||
loadReviews();
|
||||
try {
|
||||
await api.updateReview(reviewId, data);
|
||||
setEditingId(null);
|
||||
loadReviews();
|
||||
} catch (e) {
|
||||
alert(`리뷰 수정 실패: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (reviewId: string) => {
|
||||
if (!confirm("리뷰를 삭제하시겠습니까?")) return;
|
||||
await api.deleteReview(reviewId);
|
||||
loadReviews();
|
||||
try {
|
||||
await api.deleteReview(reviewId);
|
||||
loadReviews();
|
||||
} catch (e) {
|
||||
alert(`리뷰 삭제 실패: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -216,7 +212,7 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
|
||||
<>
|
||||
{reviewCount > 0 && avgRating !== null && (
|
||||
<div className="flex items-center gap-2 mb-3 text-sm">
|
||||
<StarDisplay rating={Math.round(avgRating * 2) / 2} />
|
||||
<Stars rating={Math.round(avgRating * 2) / 2} />
|
||||
<span className="font-medium">{avgRating.toFixed(1)}</span>
|
||||
<span className="text-gray-500">({reviewCount}개)</span>
|
||||
</div>
|
||||
@@ -270,7 +266,7 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
|
||||
<span className="text-sm font-medium">
|
||||
{review.user_nickname || "익명"}
|
||||
</span>
|
||||
<StarDisplay rating={review.rating} />
|
||||
<Stars rating={review.rating} />
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(review.created_at).toLocaleDateString(
|
||||
"ko-KR"
|
||||
|
||||
37
frontend/src/components/Stars.tsx
Normal file
37
frontend/src/components/Stars.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
// #281 공통 별점 컴포넌트 — ReviewSection/MemoSection/MyReviewsList 재사용.
|
||||
// 0.5 단위 시각 구분: 빈 별 위에 황색 절반 별을 절대배치 + clip으로 표시.
|
||||
|
||||
interface StarsProps {
|
||||
rating: number; // 0~5, 0.5 단위
|
||||
size?: "sm" | "md";
|
||||
showNumber?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Stars({ rating, size = "sm", showNumber = false, className = "" }: StarsProps) {
|
||||
const r = Math.max(0, Math.min(5, rating));
|
||||
const textSize = size === "md" ? "text-base" : "text-sm";
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-0.5 ${textSize} ${className}`} aria-label={`${r}점`}>
|
||||
{[1, 2, 3, 4, 5].map((i) => {
|
||||
const full = r >= i;
|
||||
const half = !full && r >= i - 0.5;
|
||||
return (
|
||||
<span key={i} className="relative inline-block leading-none">
|
||||
<span className="text-gray-300">★</span>
|
||||
{(full || half) && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 text-yellow-500 overflow-hidden"
|
||||
style={{ width: full ? "100%" : "50%" }}
|
||||
>
|
||||
★
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{showNumber && r > 0 && <span className="text-xs text-yellow-600 font-medium ml-1">{r}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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;
|
||||
channels?: string[];
|
||||
foods_mentioned?: string[];
|
||||
// #322 LLM 검증
|
||||
hidden?: boolean;
|
||||
hidden_reason?: string | null;
|
||||
verified_at?: string | null;
|
||||
}
|
||||
|
||||
export interface VideoLink {
|
||||
@@ -310,6 +314,7 @@ export const api = {
|
||||
email: string | null;
|
||||
nickname: string | null;
|
||||
avatar_url: string | null;
|
||||
is_admin: boolean;
|
||||
provider: string | null;
|
||||
created_at: string | null;
|
||||
favorite_count: number;
|
||||
@@ -320,6 +325,14 @@ export const api = {
|
||||
}>(`/api/admin/users${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
|
||||
updateAdminUserAdmin(userId: string, admin: boolean) {
|
||||
return fetchApi<{ success: boolean }>(`/api/admin/users/${userId}/admin`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ admin }),
|
||||
});
|
||||
},
|
||||
|
||||
getAdminUserFavorites(userId: string) {
|
||||
return fetchApi<
|
||||
{
|
||||
@@ -567,4 +580,30 @@ export const api = {
|
||||
{ method: "POST" }
|
||||
);
|
||||
},
|
||||
|
||||
// #322 — LLM 검증 어드민 API
|
||||
getVerifyPending() {
|
||||
return fetchApi<{ pending: number }>("/api/admin/restaurants/verify/pending");
|
||||
},
|
||||
verifyAll(batchSize: number = 10) {
|
||||
return fetchApi<{ processed: number }>(
|
||||
`/api/admin/restaurants/verify/all?batchSize=${batchSize}`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
},
|
||||
verifyOne(id: string) {
|
||||
return fetchApi<{ success: boolean; id: string }>(
|
||||
`/api/admin/restaurants/${id}/verify`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
},
|
||||
setRestaurantHidden(id: string, hidden: boolean, reason: string = "manual") {
|
||||
return fetchApi<{ success: boolean; id: string; hidden: boolean }>(
|
||||
`/api/admin/restaurants/${id}/hidden`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ hidden, reason }),
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user