22 Commits

Author SHA1 Message Date
joungmin
f2861b6b79 홈 탭 장르 카드 UI + Tabler Icons 적용 + 지역 필터 추가
- 홈 탭: 장르 가로 스크롤 카드 (Tabler Icons 픽토그램)
- 홈 탭: 가격/지역/내위치 필터 2줄 배치
- 리스트 탭: 기존 바텀시트 필터 UI 유지
- cuisine-icons: Tabler 아이콘 매핑 추가 (getTablerCuisineIcon)
- 드래그 스크롤 장르 카드에 적용
- 배포 가이드 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:52:42 +09:00
joungmin
dda0da52c4 내위치 필터 모바일 리스트 적용 + 반경 4km
- mapBounds 없을 때 userLoc 기준 ~4km 반경 필터링
- 내위치 ON 시 setUserLoc도 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:25:36 +09:00
joungmin
18776b9b4b 바텀시트 필터 글씨 크기 미세 조정
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:15:59 +09:00
joungmin
177532e6e7 모바일 필터 바텀시트 UI 적용
- FilterSheet 컴포넌트 신규: 바텀시트로 올라오는 필터 선택 UI
- 장르/가격/지역 필터 모두 네이티브 select 대신 바텀시트 사용
- 카테고리별 그룹핑 + sticky 헤더 + 선택 체크 표시
- slide-up 애니메이션 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:13:46 +09:00
joungmin
64d58cb553 모바일 필터 UI pill 스타일로 개선
- select를 둥근 칩(pill) 형태로 변경 (아이콘 + 드롭다운 화살표)
- 선택 시 브랜드 컬러 배경 + 링 하이라이트
- 장르/가격/지역 필터 모두 동일 스타일 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:08:42 +09:00
joungmin
a766a74f20 모바일 리스트 레이아웃 개선 + 내위치 줌 조정
- 식당명/지역/별점 1줄, 종류+가격(왼)+유튜브채널(우) 2줄, 태그 3줄 배치
- 가격대: 종류가 공간 우선 차지, 나머지에서 truncate
- 내위치 줌 16→17로 조정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:04:32 +09:00
joungmin
4b1f7c13b7 Playwright 제거 → DuckDuckGo HTML 검색 전환 + UI 미세 조정
- 테이블링/캐치테이블 검색: Google+Playwright → DuckDuckGo HTML 파싱 (브라우저 불필요)
- 검색 딜레이 5~15초 → 2~5초로 단축
- 프론트엔드: 정보 텍스트 계층 개선 (폰트 크기/색상 조정)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:28:49 +09:00
joungmin
75e0066dbe 지도 마커 클러스터링 적용 (supercluster)
- 줌 16 이하에서 근접 마커를 숫자 원형 클러스터로 묶음
- 클러스터 클릭 시 해당 영역으로 자동 줌인
- 개별 마커 스타일/채널 색상 유지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:16:31 +09:00
joungmin
3134994817 비공개 메모 기능 추가 + 아이콘 개선
- 식당별 1:1 비공개 메모 CRUD (user_memos 테이블)
- 내 기록에 리뷰/메모 탭 분리
- 백오피스 유저 관리에 메모 수/상세 표시
- 리뷰/메모 작성 시 현재 날짜 기본값
- 지도우선/목록우선 버튼 Material Symbols 아이콘 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:10:06 +09:00
joungmin
88c1b4243e 로고 라이트 고정 + color-scheme only light 강화
- 다크/라이트 로고 전환 로직 제거, 라이트 로고 고정
- color-scheme: only light !important 강화
- supported-color-schemes 메타 태그 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:17:33 +09:00
joungmin
824c171158 Material Symbols 아이콘 전환 + 로고 이미지 적용 + 테이블링 이름 유사도 체크
- 전체 인라인 SVG를 Google Material Symbols Rounded로 교체
- Icon 컴포넌트 추가, cuisine-icons 매핑 리팩토링
- Tasteby 핀 로고 이미지 적용 (라이트/다크 버전)
- 테이블링/캐치테이블 이름 유사도 체크 및 리셋 API 추가
- 어드민 페이지 리셋 버튼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:55:04 +09:00
joungmin
4f8b4f435e 라이트 테마 강제 적용 + surface 토큰 + 텍스트 대비 개선
- dark: 클래스를 class 기반으로 전환 (다크모드 비활성화)
- color-scheme: light 강제
- surface 색상 토큰 추가 (카드/패널용)
- 리스트/사이드바 배경 bg-surface로 통일
- 텍스트 대비 강화 (gray-400 → gray-500/700)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:30:49 +09:00
joungmin
50018c17fa Saffron 디자인 시스템 적용: 브랜드 컬러 + Pretendard 폰트 + 크림 배경
- CSS 변수 기반 brand-50~950 컬러 팔레트 추가 (Tailwind @theme inline)
- Pretendard Variable 폰트 로드 및 기본 폰트로 설정
- 라이트모드 배경 #FFFAF5 크림색 적용 (다크모드 기본 유지)
- 전체 컴포넌트 orange-* → brand-* 마이그레이션
- 식당 리스트 채널명에 YouTube SVG 아이콘 추가
- 디자인 컨셉 문서 추가 (docs/design-concepts.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:15:45 +09:00
joungmin
ec8330a978 모바일 UI 개선: 내주변 지도전용, 필터 상시노출, 채널필터 전탭 확장
- 내주변 탭: 지도만 전체 표시 (리스트 제거), 마커 클릭 시 바텀시트 상세보기 유지
- 유튜버 채널 필터: 홈/식당목록/내주변 탭 모두에서 표시
- 모바일 필터: 토글 패널 → 항상 보이는 2줄 레이아웃 (장르+가격 / 나라+내위치)
- 모바일 헤더에 찜/리뷰 버튼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:49:42 +09:00
joungmin
e85e135c8b 검색/필터 UI 개선, 채널 정렬, 드래그 스크롤, 지도링크 수정
- 검색바: 아이콘 내장, 모드 select 제거 (hybrid 고정), 엔터 검색
- 필터 그룹화: [음식 장르·가격] [지역 나라·시·구] + X 해제 버튼
- 채널 필터: 드롭다운 → 유튜브 아이콘 토글 카드, 드래그 스크롤
- 채널 정렬: sort_order 컬럼 추가, 백오피스 순서 편집
- 채널+필터 동시 적용: API 호출 대신 클라이언트 필터링
- 내위치 ON 시 다른 필터 초기화, 역방향도 동일
- 전체보기 버튼: 모든 필터 일괄 해제
- 네이버맵: 한국 식당만, 식당명만 검색
- 구글맵: 식당명+주소/지역 검색
- 로그인 영역 데스크톱 Row 1 우측 배치
- scrollbar-hide CSS 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:42:25 +09:00
joungmin
2a0ee1d2cc 채널 카드 필터 UI, 캐시 초기화, 나라 필터 수정
- 채널 설명/태그 DB 컬럼 추가 및 백오피스 편집 기능
- 채널 드롭다운을 유튜브 아이콘 토글 카드로 변경 (데스크톱 최대 4개 표시, 스크롤)
- 모바일 홈탭 채널 카드 가로 스크롤
- region "나라" 값 필터 옵션에서 제외
- 관리자 캐시 초기화 버튼 및 API 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:10:21 +09:00
joungmin
0f985d52a9 벌크 자막/추출 개선, 검색 필터 무시, geocoding 필드 수정, 네이버맵 링크
- 벌크 자막: 브라우저 우선 + API fallback, 광고 즉시 skip, 대기 시간 단축
- 벌크 자막/추출: 선택한 영상만 처리 가능 (체크박스 선택 후 실행)
- 자막 실패 시 no_transcript 상태 마킹하여 재시도 방지
- 검색 시 필터 조건 무시 (채널/장르/가격/지역/영역 초기화)
- 리셋 버튼 클릭 시 검색어 입력란 초기화
- RestaurantMapper updateFields에 google_place_id, rating 등 geocoding 필드 추가
- SearchMapper에 tabling_url, catchtable_url, phone, website 필드 추가
- 식당 상세에 네이버 지도 링크 추가
- YouTubeService.getTranscriptApi public 전환

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:00:40 +09:00
joungmin
cdee37e341 UI/UX 개선: 모바일 네비게이션, 로그인 모달, 지도 기능, 캐치테이블 연동
- 모바일 하단 네비게이션(홈/식당목록/내주변/찜/내정보) 추가
- 로그인 버튼을 모달 방식으로 변경 (소셜 로그인 확장 가능)
- 내위치 기반 정렬 및 영역 필터, 지도 내위치 버튼 추가
- 채널 필터 시 해당 채널만 마커/범례 표시
- 캐치테이블 검색/연동 (단건/벌크), NONE 저장 패턴
- 벌크 트랜스크립트 SSE (Playwright 브라우저 재사용)
- 테이블링/캐치테이블 버튼 UI 차별화
- Google Maps 링크 모바일 호환, 초기화 버튼, 검색 라벨 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:49:16 +09:00
joungmin
58c0f972e2 Add .DS_Store and cookies.txt to gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:31:37 +09:00
joungmin
0ad09e5b67 Add dockerignore, fix Redis image, add troubleshooting docs
- Add .dockerignore for backend-java and frontend (276MB → 336KB)
- Fix Redis image to use full registry path (CRI-O compatibility)
- Update ingress TLS to www only (root domain DNS pending)
- Add comprehensive troubleshooting documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:23:42 +09:00
joungmin
7a896c8c56 Fix build_spec for ARM64 cross-build with buildx/QEMU, add IAM docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:53:02 +09:00
joungmin
745913ca5b Add OCI DevOps build spec and CI/CD architecture docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:22:04 +09:00
70 changed files with 5174 additions and 880 deletions

4
.gitignore vendored
View File

@@ -13,3 +13,7 @@ backend-java/.gradle/
# K8s secrets (never commit) # K8s secrets (never commit)
k8s/secrets.yaml k8s/secrets.yaml
# OS / misc
.DS_Store
backend/cookies.txt

View File

@@ -0,0 +1,4 @@
build/
.gradle/
.idea/
*.iml

View File

@@ -1,16 +1,29 @@
package com.tasteby.config; package com.tasteby.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
import javax.sql.DataSource;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
@Configuration @Configuration
public class DataSourceConfig { public class DataSourceConfig {
private static final Logger log = LoggerFactory.getLogger(DataSourceConfig.class);
@Value("${app.oracle.wallet-path:}") @Value("${app.oracle.wallet-path:}")
private String walletPath; private String walletPath;
private final DataSource dataSource;
public DataSourceConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@PostConstruct @PostConstruct
public void configureWallet() { public void configureWallet() {
if (walletPath != null && !walletPath.isBlank()) { if (walletPath != null && !walletPath.isBlank()) {
@@ -18,4 +31,23 @@ public class DataSourceConfig {
System.setProperty("oracle.net.wallet_location", walletPath); System.setProperty("oracle.net.wallet_location", walletPath);
} }
} }
@EventListener(ApplicationReadyEvent.class)
public void runMigrations() {
migrate("ALTER TABLE restaurants ADD (tabling_url VARCHAR2(500))");
migrate("ALTER TABLE restaurants ADD (catchtable_url VARCHAR2(500))");
}
private void migrate(String sql) {
try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
stmt.execute(sql);
log.info("[MIGRATE] {}", sql);
} catch (Exception e) {
if (e.getMessage() != null && e.getMessage().contains("ORA-01430")) {
log.debug("[MIGRATE] already done: {}", sql);
} else {
log.warn("[MIGRATE] failed: {} - {}", sql, e.getMessage());
}
}
}
} }

View File

@@ -0,0 +1,25 @@
package com.tasteby.controller;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.CacheService;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/admin")
public class AdminCacheController {
private final CacheService cacheService;
public AdminCacheController(CacheService cacheService) {
this.cacheService = cacheService;
}
@PostMapping("/cache-flush")
public Map<String, Object> flushCache() {
AuthUtil.requireAdmin();
cacheService.flush();
return Map.of("ok", true);
}
}

View File

@@ -1,7 +1,9 @@
package com.tasteby.controller; package com.tasteby.controller;
import com.tasteby.domain.Memo;
import com.tasteby.domain.Restaurant; import com.tasteby.domain.Restaurant;
import com.tasteby.domain.Review; import com.tasteby.domain.Review;
import com.tasteby.service.MemoService;
import com.tasteby.service.ReviewService; import com.tasteby.service.ReviewService;
import com.tasteby.service.UserService; import com.tasteby.service.UserService;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -15,10 +17,12 @@ public class AdminUserController {
private final UserService userService; private final UserService userService;
private final ReviewService reviewService; private final ReviewService reviewService;
private final MemoService memoService;
public AdminUserController(UserService userService, ReviewService reviewService) { public AdminUserController(UserService userService, ReviewService reviewService, MemoService memoService) {
this.userService = userService; this.userService = userService;
this.reviewService = reviewService; this.reviewService = reviewService;
this.memoService = memoService;
} }
@GetMapping @GetMapping
@@ -39,4 +43,9 @@ public class AdminUserController {
public List<Review> userReviews(@PathVariable String userId) { public List<Review> userReviews(@PathVariable String userId) {
return reviewService.findByUser(userId, 100, 0); return reviewService.findByUser(userId, 100, 0);
} }
@GetMapping("/{userId}/memos")
public List<Memo> userMemos(@PathVariable String userId) {
return memoService.findByUser(userId);
}
} }

View File

@@ -6,6 +6,7 @@ import com.tasteby.domain.Channel;
import com.tasteby.security.AuthUtil; 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 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;
@@ -18,11 +19,14 @@ import java.util.Map;
public class ChannelController { public class ChannelController {
private final ChannelService channelService; private final ChannelService channelService;
private final YouTubeService youtubeService;
private final CacheService cache; private final CacheService cache;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public ChannelController(ChannelService channelService, CacheService cache, ObjectMapper objectMapper) { public ChannelController(ChannelService channelService, YouTubeService youtubeService,
CacheService cache, ObjectMapper objectMapper) {
this.channelService = channelService; this.channelService = channelService;
this.youtubeService = youtubeService;
this.cache = cache; this.cache = cache;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@@ -60,6 +64,27 @@ public class ChannelController {
} }
} }
@PostMapping("/{channelId}/scan")
public Map<String, Object> scan(@PathVariable String channelId,
@RequestParam(defaultValue = "false") boolean full) {
AuthUtil.requireAdmin();
var result = youtubeService.scanChannel(channelId, full);
if (result == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Channel not found");
}
cache.flush();
return result;
}
@PutMapping("/{id}")
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
AuthUtil.requireAdmin();
Integer sortOrder = body.get("sort_order") != null ? ((Number) body.get("sort_order")).intValue() : null;
channelService.update(id, (String) body.get("description"), (String) body.get("tags"), sortOrder);
cache.flush();
return Map.of("ok", true);
}
@DeleteMapping("/{channelId}") @DeleteMapping("/{channelId}")
public Map<String, Object> delete(@PathVariable String channelId) { public Map<String, Object> delete(@PathVariable String channelId) {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();

View File

@@ -0,0 +1,59 @@
package com.tasteby.controller;
import com.tasteby.domain.Memo;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.MemoService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class MemoController {
private final MemoService memoService;
public MemoController(MemoService memoService) {
this.memoService = memoService;
}
@GetMapping("/restaurants/{restaurantId}/memo")
public Memo getMemo(@PathVariable String restaurantId) {
String userId = AuthUtil.getUserId();
Memo memo = memoService.findByUserAndRestaurant(userId, restaurantId);
if (memo == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No memo");
}
return memo;
}
@PostMapping("/restaurants/{restaurantId}/memo")
public Memo upsertMemo(@PathVariable String restaurantId,
@RequestBody Map<String, Object> body) {
String userId = AuthUtil.getUserId();
Double rating = body.get("rating") != null
? ((Number) body.get("rating")).doubleValue() : null;
String text = (String) body.get("memo_text");
LocalDate visitedAt = body.get("visited_at") != null
? LocalDate.parse((String) body.get("visited_at")) : null;
return memoService.upsert(userId, restaurantId, rating, text, visitedAt);
}
@GetMapping("/users/me/memos")
public List<Memo> myMemos() {
return memoService.findByUser(AuthUtil.getUserId());
}
@DeleteMapping("/restaurants/{restaurantId}/memo")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteMemo(@PathVariable String restaurantId) {
String userId = AuthUtil.getUserId();
if (!memoService.delete(userId, restaurantId)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No memo");
}
}
}

View File

@@ -5,24 +5,44 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.tasteby.domain.Restaurant; import com.tasteby.domain.Restaurant;
import com.tasteby.security.AuthUtil; import com.tasteby.security.AuthUtil;
import com.tasteby.service.CacheService; import com.tasteby.service.CacheService;
import com.tasteby.service.GeocodingService;
import com.tasteby.service.RestaurantService; import com.tasteby.service.RestaurantService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List; import java.net.URI;
import java.util.Map; import java.net.URLDecoder;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@RestController @RestController
@RequestMapping("/api/restaurants") @RequestMapping("/api/restaurants")
public class RestaurantController { public class RestaurantController {
private static final Logger log = LoggerFactory.getLogger(RestaurantController.class);
private final RestaurantService restaurantService; private final RestaurantService restaurantService;
private final GeocodingService geocodingService;
private final CacheService cache; private final CacheService cache;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
public RestaurantController(RestaurantService restaurantService, CacheService cache, ObjectMapper objectMapper) { public RestaurantController(RestaurantService restaurantService, GeocodingService geocodingService, CacheService cache, ObjectMapper objectMapper) {
this.restaurantService = restaurantService; this.restaurantService = restaurantService;
this.geocodingService = geocodingService;
this.cache = cache; this.cache = cache;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@@ -68,11 +88,43 @@ public class RestaurantController {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
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");
// Re-geocode if name or address changed
String newName = (String) body.get("name");
String newAddress = (String) body.get("address");
boolean nameChanged = newName != null && !newName.equals(r.getName());
boolean addressChanged = newAddress != null && !newAddress.equals(r.getAddress());
if (nameChanged || addressChanged) {
String geoName = newName != null ? newName : r.getName();
String geoAddr = newAddress != null ? newAddress : r.getAddress();
var geo = geocodingService.geocodeRestaurant(geoName, geoAddr);
if (geo != null) {
body.put("latitude", geo.get("latitude"));
body.put("longitude", geo.get("longitude"));
body.put("google_place_id", geo.get("google_place_id"));
if (geo.containsKey("formatted_address")) {
body.put("address", geo.get("formatted_address"));
}
if (geo.containsKey("rating")) body.put("rating", geo.get("rating"));
if (geo.containsKey("rating_count")) body.put("rating_count", geo.get("rating_count"));
if (geo.containsKey("phone")) body.put("phone", geo.get("phone"));
if (geo.containsKey("business_status")) body.put("business_status", geo.get("business_status"));
// formatted_address에서 region 파싱 (예: "대한민국 서울특별시 강남구 ..." → "한국|서울|강남구")
String addr = (String) geo.get("formatted_address");
if (addr != null) {
body.put("region", GeocodingService.parseRegionFromAddress(addr));
}
}
}
restaurantService.update(id, body); restaurantService.update(id, body);
cache.flush(); cache.flush();
return Map.of("ok", true); var updated = restaurantService.findById(id);
return Map.of("ok", true, "restaurant", updated);
} }
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public Map<String, Object> delete(@PathVariable String id) { public Map<String, Object> delete(@PathVariable String id) {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
@@ -83,6 +135,243 @@ public class RestaurantController {
return Map.of("ok", true); return Map.of("ok", true);
} }
/** 단건 테이블링 URL 검색 */
@GetMapping("/{id}/tabling-search")
public List<Map<String, Object>> tablingSearch(@PathVariable String id) {
AuthUtil.requireAdmin();
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
try {
return searchTabling(r.getName());
} catch (Exception e) {
log.error("[TABLING] Search failed for '{}': {}", r.getName(), e.getMessage());
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
}
}
/** 테이블링 미연결 식당 목록 */
@GetMapping("/tabling-pending")
public Map<String, Object> tablingPending() {
AuthUtil.requireAdmin();
var list = restaurantService.findWithoutTabling();
var summary = list.stream()
.map(r -> Map.of("id", (Object) r.getId(), "name", (Object) r.getName()))
.toList();
return Map.of("count", list.size(), "restaurants", summary);
}
/** 벌크 테이블링 검색 (SSE) */
@PostMapping("/bulk-tabling")
public SseEmitter bulkTabling() {
AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L);
executor.execute(() -> {
try {
var restaurants = restaurantService.findWithoutTabling();
int total = restaurants.size();
emit(emitter, Map.of("type", "start", "total", total));
if (total == 0) {
emit(emitter, Map.of("type", "complete", "total", 0, "linked", 0, "notFound", 0));
emitter.complete();
return;
}
int linked = 0;
int notFound = 0;
for (int i = 0; i < total; i++) {
var r = restaurants.get(i);
emit(emitter, Map.of("type", "processing", "current", i + 1,
"total", total, "name", r.getName()));
try {
var results = searchTabling(r.getName());
if (!results.isEmpty()) {
String url = String.valueOf(results.get(0).get("url"));
String title = String.valueOf(results.get(0).get("title"));
if (isNameSimilar(r.getName(), title)) {
restaurantService.update(r.getId(), Map.of("tabling_url", url));
linked++;
emit(emitter, Map.of("type", "done", "current", i + 1,
"name", r.getName(), "url", url, "title", title));
} else {
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
notFound++;
log.info("[TABLING] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
emit(emitter, Map.of("type", "notfound", "current", i + 1,
"name", r.getName(), "reason", "이름 불일치: " + title));
}
} else {
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
notFound++;
emit(emitter, Map.of("type", "notfound", "current", i + 1,
"name", r.getName()));
}
} catch (Exception e) {
notFound++;
emit(emitter, Map.of("type", "error", "current", i + 1,
"name", r.getName(), "message", e.getMessage()));
}
// 랜덤 딜레이 (2~5초)
int delay = ThreadLocalRandom.current().nextInt(2000, 5001);
log.info("[TABLING] Waiting {}ms before next search...", delay);
Thread.sleep(delay);
}
cache.flush();
emit(emitter, Map.of("type", "complete", "total", total, "linked", linked, "notFound", notFound));
emitter.complete();
} catch (Exception e) {
log.error("[TABLING] Bulk search error", e);
emitter.completeWithError(e);
}
});
return emitter;
}
/** 테이블링 URL 저장 */
@PutMapping("/{id}/tabling-url")
public Map<String, Object> setTablingUrl(@PathVariable String id, @RequestBody Map<String, String> body) {
AuthUtil.requireAdmin();
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
String url = body.get("tabling_url");
restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
cache.flush();
return Map.of("ok", true);
}
/** 테이블링/캐치테이블 매핑 초기화 */
@DeleteMapping("/reset-tabling")
public Map<String, Object> resetTabling() {
AuthUtil.requireAdmin();
restaurantService.resetTablingUrls();
cache.flush();
return Map.of("ok", true);
}
@DeleteMapping("/reset-catchtable")
public Map<String, Object> resetCatchtable() {
AuthUtil.requireAdmin();
restaurantService.resetCatchtableUrls();
cache.flush();
return Map.of("ok", true);
}
/** 단건 캐치테이블 URL 검색 */
@GetMapping("/{id}/catchtable-search")
public List<Map<String, Object>> catchtableSearch(@PathVariable String id) {
AuthUtil.requireAdmin();
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
try {
return searchCatchtable(r.getName());
} catch (Exception e) {
log.error("[CATCHTABLE] Search failed for '{}': {}", r.getName(), e.getMessage());
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
}
}
/** 캐치테이블 미연결 식당 목록 */
@GetMapping("/catchtable-pending")
public Map<String, Object> catchtablePending() {
AuthUtil.requireAdmin();
var list = restaurantService.findWithoutCatchtable();
var summary = list.stream()
.map(r -> Map.of("id", (Object) r.getId(), "name", (Object) r.getName()))
.toList();
return Map.of("count", list.size(), "restaurants", summary);
}
/** 벌크 캐치테이블 검색 (SSE) */
@PostMapping("/bulk-catchtable")
public SseEmitter bulkCatchtable() {
AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L);
executor.execute(() -> {
try {
var restaurants = restaurantService.findWithoutCatchtable();
int total = restaurants.size();
emit(emitter, Map.of("type", "start", "total", total));
if (total == 0) {
emit(emitter, Map.of("type", "complete", "total", 0, "linked", 0, "notFound", 0));
emitter.complete();
return;
}
int linked = 0;
int notFound = 0;
for (int i = 0; i < total; i++) {
var r = restaurants.get(i);
emit(emitter, Map.of("type", "processing", "current", i + 1,
"total", total, "name", r.getName()));
try {
var results = searchCatchtable(r.getName());
if (!results.isEmpty()) {
String url = String.valueOf(results.get(0).get("url"));
String title = String.valueOf(results.get(0).get("title"));
if (isNameSimilar(r.getName(), title)) {
restaurantService.update(r.getId(), Map.of("catchtable_url", url));
linked++;
emit(emitter, Map.of("type", "done", "current", i + 1,
"name", r.getName(), "url", url, "title", title));
} else {
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
notFound++;
log.info("[CATCHTABLE] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
emit(emitter, Map.of("type", "notfound", "current", i + 1,
"name", r.getName(), "reason", "이름 불일치: " + title));
}
} else {
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
notFound++;
emit(emitter, Map.of("type", "notfound", "current", i + 1,
"name", r.getName()));
}
} catch (Exception e) {
notFound++;
emit(emitter, Map.of("type", "error", "current", i + 1,
"name", r.getName(), "message", e.getMessage()));
}
int delay = ThreadLocalRandom.current().nextInt(2000, 5001);
log.info("[CATCHTABLE] Waiting {}ms before next search...", delay);
Thread.sleep(delay);
}
cache.flush();
emit(emitter, Map.of("type", "complete", "total", total, "linked", linked, "notFound", notFound));
emitter.complete();
} catch (Exception e) {
log.error("[CATCHTABLE] Bulk search error", e);
emitter.completeWithError(e);
}
});
return emitter;
}
/** 캐치테이블 URL 저장 */
@PutMapping("/{id}/catchtable-url")
public Map<String, Object> setCatchtableUrl(@PathVariable String id, @RequestBody Map<String, String> body) {
AuthUtil.requireAdmin();
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
String url = body.get("catchtable_url");
restaurantService.update(id, Map.of("catchtable_url", url != null ? url : ""));
cache.flush();
return Map.of("ok", true);
}
@GetMapping("/{id}/videos") @GetMapping("/{id}/videos")
public List<Map<String, Object>> videos(@PathVariable String id) { public List<Map<String, Object>> videos(@PathVariable String id) {
String key = cache.makeKey("restaurant_videos", id); String key = cache.makeKey("restaurant_videos", id);
@@ -98,4 +387,129 @@ public class RestaurantController {
cache.set(key, result); cache.set(key, result);
return result; return result;
} }
// ─── DuckDuckGo HTML search helpers ─────────────────────────────────
private static final HttpClient httpClient = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
private static final Pattern DDG_RESULT_PATTERN = Pattern.compile(
"<a[^>]+class=\"result__a\"[^>]+href=\"([^\"]+)\"[^>]*>(.*?)</a>",
Pattern.DOTALL
);
/**
* DuckDuckGo HTML 검색을 통해 특정 사이트의 URL을 찾는다.
* html.duckduckgo.com은 서버사이드 렌더링이라 봇 판정 없이 HTTP 요청만으로 결과를 파싱할 수 있다.
*/
private List<Map<String, Object>> searchDuckDuckGo(String query, String... urlPatterns) throws Exception {
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
String searchUrl = "https://html.duckduckgo.com/html/?q=" + encoded;
log.info("[DDG] Searching: {}", query);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(searchUrl))
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
.header("Accept", "text/html,application/xhtml+xml")
.header("Accept-Language", "ko-KR,ko;q=0.9")
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
String html = response.body();
List<Map<String, Object>> results = new ArrayList<>();
Set<String> seen = new HashSet<>();
Matcher matcher = DDG_RESULT_PATTERN.matcher(html);
while (matcher.find() && results.size() < 5) {
String href = matcher.group(1);
String title = matcher.group(2).replaceAll("<[^>]+>", "").trim();
// DDG 링크에서 실제 URL 추출 (uddg 파라미터)
String actualUrl = extractDdgUrl(href);
if (actualUrl == null) continue;
boolean matches = false;
for (String pattern : urlPatterns) {
if (actualUrl.contains(pattern)) {
matches = true;
break;
}
}
if (matches && !seen.contains(actualUrl)) {
seen.add(actualUrl);
results.add(Map.of("title", title, "url", actualUrl));
}
}
log.info("[DDG] Found {} results for '{}'", results.size(), query);
return results;
}
/** DDG 리다이렉트 URL에서 실제 URL 추출 */
private String extractDdgUrl(String ddgHref) {
try {
// //duckduckgo.com/l/?uddg=ENCODED_URL&rut=...
if (ddgHref.contains("uddg=")) {
String uddgParam = ddgHref.substring(ddgHref.indexOf("uddg=") + 5);
int ampIdx = uddgParam.indexOf('&');
if (ampIdx > 0) uddgParam = uddgParam.substring(0, ampIdx);
return URLDecoder.decode(uddgParam, StandardCharsets.UTF_8);
}
// 직접 URL인 경우
if (ddgHref.startsWith("http")) return ddgHref;
} catch (Exception e) {
log.debug("[DDG] Failed to extract URL from: {}", ddgHref);
}
return null;
}
private List<Map<String, Object>> searchTabling(String restaurantName) throws Exception {
return searchDuckDuckGo(
"site:tabling.co.kr " + restaurantName,
"tabling.co.kr/restaurant/", "tabling.co.kr/place/"
);
}
private List<Map<String, Object>> searchCatchtable(String restaurantName) throws Exception {
return searchDuckDuckGo(
"site:app.catchtable.co.kr " + restaurantName,
"catchtable.co.kr/dining/", "catchtable.co.kr/shop/"
);
}
/**
* 식당 이름과 검색 결과 제목의 유사도 검사.
* 한쪽 이름이 다른쪽에 포함되거나, 공통 글자 비율이 40% 이상이면 유사하다고 판단.
*/
private boolean isNameSimilar(String restaurantName, String resultTitle) {
String a = normalize(restaurantName);
String b = normalize(resultTitle);
if (a.isEmpty() || b.isEmpty()) return false;
// 포함 관계 체크
if (a.contains(b) || b.contains(a)) return true;
// 공통 문자 비율 (Jaccard-like)
var setA = a.chars().boxed().collect(java.util.stream.Collectors.toSet());
var setB = b.chars().boxed().collect(java.util.stream.Collectors.toSet());
long common = setA.stream().filter(setB::contains).count();
double ratio = (double) common / Math.max(setA.size(), setB.size());
return ratio >= 0.4;
}
private String normalize(String s) {
if (s == null) return "";
return s.replaceAll("[\\\\-_()\\[\\]【】]", "").toLowerCase();
}
private void emit(SseEmitter emitter, Map<String, Object> data) {
try {
emitter.send(SseEmitter.event().data(objectMapper.writeValueAsString(data)));
} catch (Exception e) {
log.debug("SSE emit error: {}", e.getMessage());
}
}
} }

View File

@@ -103,6 +103,24 @@ public class VideoController {
return Map.of("ok", true, "length", result.text().length(), "source", result.source()); return Map.of("ok", true, "length", result.text().length(), "source", result.source());
} }
/** 클라이언트(브라우저)에서 가져온 트랜스크립트를 저장 */
@PostMapping("/{id}/upload-transcript")
public Map<String, Object> uploadTranscript(@PathVariable String id,
@RequestBody Map<String, String> body) {
AuthUtil.requireAdmin();
var video = videoService.findDetail(id);
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
String text = body.get("text");
if (text == null || text.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "text is required");
}
videoService.updateTranscript(id, text);
String source = body.getOrDefault("source", "browser");
return Map.of("ok", true, "length", text.length(), "source", source);
}
@GetMapping("/extract/prompt") @GetMapping("/extract/prompt")
public Map<String, Object> getExtractPrompt() { public Map<String, Object> getExtractPrompt() {
return Map.of("prompt", extractorService.getPrompt()); return Map.of("prompt", extractorService.getPrompt());
@@ -234,6 +252,34 @@ public class VideoController {
if (body.containsKey(key)) restFields.put(key, body.get(key)); if (body.containsKey(key)) restFields.put(key, body.get(key));
} }
if (!restFields.isEmpty()) { if (!restFields.isEmpty()) {
// Re-geocode if name or address changed
var existing = restaurantService.findById(restaurantId);
String newName = (String) restFields.get("name");
String newAddr = (String) restFields.get("address");
boolean nameChanged = newName != null && existing != null && !newName.equals(existing.getName());
boolean addrChanged = newAddr != null && existing != null && !newAddr.equals(existing.getAddress());
if (nameChanged || addrChanged) {
String geoName = newName != null ? newName : existing.getName();
String geoAddr = newAddr != null ? newAddr : existing.getAddress();
var geo = geocodingService.geocodeRestaurant(geoName, geoAddr);
if (geo != null) {
restFields.put("latitude", geo.get("latitude"));
restFields.put("longitude", geo.get("longitude"));
restFields.put("google_place_id", geo.get("google_place_id"));
if (geo.containsKey("formatted_address")) {
restFields.put("address", geo.get("formatted_address"));
}
if (geo.containsKey("rating")) restFields.put("rating", geo.get("rating"));
if (geo.containsKey("rating_count")) restFields.put("rating_count", geo.get("rating_count"));
if (geo.containsKey("phone")) restFields.put("phone", geo.get("phone"));
if (geo.containsKey("business_status")) restFields.put("business_status", geo.get("business_status"));
// Parse region from address
String addr = (String) geo.get("formatted_address");
if (addr != null) {
restFields.put("region", GeocodingService.parseRegionFromAddress(addr));
}
}
}
restaurantService.update(restaurantId, restFields); restaurantService.update(restaurantId, restFields);
} }

View File

@@ -13,6 +13,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.*; import java.util.*;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
/** /**
* SSE streaming endpoints for bulk operations. * SSE streaming endpoints for bulk operations.
@@ -26,6 +27,7 @@ public class VideoSseController {
private final VideoService videoService; private final VideoService videoService;
private final RestaurantService restaurantService; private final RestaurantService restaurantService;
private final PipelineService pipelineService; private final PipelineService pipelineService;
private final YouTubeService youTubeService;
private final OciGenAiService genAi; private final OciGenAiService genAi;
private final CacheService cache; private final CacheService cache;
private final ObjectMapper mapper; private final ObjectMapper mapper;
@@ -34,27 +36,120 @@ public class VideoSseController {
public VideoSseController(VideoService videoService, public VideoSseController(VideoService videoService,
RestaurantService restaurantService, RestaurantService restaurantService,
PipelineService pipelineService, PipelineService pipelineService,
YouTubeService youTubeService,
OciGenAiService genAi, OciGenAiService genAi,
CacheService cache, CacheService cache,
ObjectMapper mapper) { ObjectMapper mapper) {
this.videoService = videoService; this.videoService = videoService;
this.restaurantService = restaurantService; this.restaurantService = restaurantService;
this.pipelineService = pipelineService; this.pipelineService = pipelineService;
this.youTubeService = youTubeService;
this.genAi = genAi; this.genAi = genAi;
this.cache = cache; this.cache = cache;
this.mapper = mapper; this.mapper = mapper;
} }
@PostMapping("/bulk-transcript") @PostMapping("/bulk-transcript")
public SseEmitter bulkTranscript() { public SseEmitter bulkTranscript(@RequestBody(required = false) Map<String, Object> body) {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L); // 10 min timeout SseEmitter emitter = new SseEmitter(1_800_000L); // 30 min timeout
@SuppressWarnings("unchecked")
List<String> selectedIds = body != null && body.containsKey("ids")
? ((List<?>) body.get("ids")).stream().map(Object::toString).toList()
: null;
executor.execute(() -> { executor.execute(() -> {
try { try {
// TODO: Implement when transcript extraction is available in Java var videos = selectedIds != null && !selectedIds.isEmpty()
emit(emitter, Map.of("type", "start", "total", 0)); ? videoService.findVideosByIds(selectedIds)
emit(emitter, Map.of("type", "complete", "total", 0, "success", 0)); : videoService.findVideosWithoutTranscript();
int total = videos.size();
emit(emitter, Map.of("type", "start", "total", total));
if (total == 0) {
emit(emitter, Map.of("type", "complete", "total", 0, "success", 0, "failed", 0));
emitter.complete();
return;
}
int success = 0;
int failed = 0;
// Pass 1: 브라우저 우선 (봇 탐지 회피)
var apiNeeded = new ArrayList<Integer>();
try (var session = youTubeService.createBrowserSession()) {
for (int i = 0; i < total; i++) {
var v = videos.get(i);
String videoId = (String) v.get("video_id");
String title = (String) v.get("title");
String id = (String) v.get("id");
emit(emitter, Map.of("type", "processing", "index", i, "title", title, "method", "browser"));
try {
var result = youTubeService.getTranscriptWithPage(session.page(), videoId);
if (result != null) {
videoService.updateTranscript(id, result.text());
success++;
emit(emitter, Map.of("type", "done", "index", i,
"title", title, "source", result.source(),
"length", result.text().length()));
} else {
apiNeeded.add(i);
emit(emitter, Map.of("type", "skip", "index", i,
"title", title, "message", "브라우저 실패, API로 재시도 예정"));
}
} catch (Exception e) {
apiNeeded.add(i);
log.warn("[BULK-TRANSCRIPT] Browser failed for {}: {}", videoId, e.getMessage());
}
// 봇 판정 방지 랜덤 딜레이 (3~8초)
if (i < total - 1) {
int delay = ThreadLocalRandom.current().nextInt(3000, 8001);
log.info("[BULK-TRANSCRIPT] Waiting {}ms before next...", delay);
session.page().waitForTimeout(delay);
}
}
}
// Pass 2: 브라우저 실패분만 API로 재시도
if (!apiNeeded.isEmpty()) {
emit(emitter, Map.of("type", "api_pass", "count", apiNeeded.size()));
for (int i : apiNeeded) {
var v = videos.get(i);
String videoId = (String) v.get("video_id");
String title = (String) v.get("title");
String id = (String) v.get("id");
emit(emitter, Map.of("type", "processing", "index", i, "title", title, "method", "api"));
try {
var result = youTubeService.getTranscriptApi(videoId, "auto");
if (result != null) {
videoService.updateTranscript(id, result.text());
success++;
emit(emitter, Map.of("type", "done", "index", i,
"title", title, "source", result.source(),
"length", result.text().length()));
} else {
failed++;
videoService.updateStatus(id, "no_transcript");
emit(emitter, Map.of("type", "error", "index", i,
"title", title, "message", "자막을 찾을 수 없음"));
}
} catch (Exception e) {
failed++;
videoService.updateStatus(id, "no_transcript");
log.error("[BULK-TRANSCRIPT] API error for {}: {}", videoId, e.getMessage());
emit(emitter, Map.of("type", "error", "index", i,
"title", title, "message", e.getMessage()));
}
}
}
emit(emitter, Map.of("type", "complete", "total", total, "success", success, "failed", failed));
emitter.complete(); emitter.complete();
} catch (Exception e) { } catch (Exception e) {
log.error("Bulk transcript error", e); log.error("Bulk transcript error", e);
@@ -65,13 +160,20 @@ public class VideoSseController {
} }
@PostMapping("/bulk-extract") @PostMapping("/bulk-extract")
public SseEmitter bulkExtract() { public SseEmitter bulkExtract(@RequestBody(required = false) Map<String, Object> body) {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L); SseEmitter emitter = new SseEmitter(600_000L);
@SuppressWarnings("unchecked")
List<String> selectedIds = body != null && body.containsKey("ids")
? ((List<?>) body.get("ids")).stream().map(Object::toString).toList()
: null;
executor.execute(() -> { executor.execute(() -> {
try { try {
var rows = videoService.findVideosForBulkExtract(); var rows = selectedIds != null && !selectedIds.isEmpty()
? videoService.findVideosForExtractByIds(selectedIds)
: videoService.findVideosForBulkExtract();
int total = rows.size(); int total = rows.size();
int totalRestaurants = 0; int totalRestaurants = 0;

View File

@@ -14,6 +14,9 @@ public class Channel {
private String channelId; private String channelId;
private String channelName; private String channelName;
private String titleFilter; private String titleFilter;
private String description;
private String tags;
private Integer sortOrder;
private int videoCount; private int videoCount;
private String lastVideoAt; private String lastVideoAt;
} }

View File

@@ -0,0 +1,22 @@
package com.tasteby.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Memo {
private String id;
private String userId;
private String restaurantId;
private Double rating;
private String memoText;
private String visitedAt;
private String createdAt;
private String updatedAt;
private String restaurantName;
}

View File

@@ -24,6 +24,8 @@ public class Restaurant {
private String phone; private String phone;
private String website; private String website;
private String googlePlaceId; private String googlePlaceId;
private String tablingUrl;
private String catchtableUrl;
private String businessStatus; private String businessStatus;
private Double rating; private Double rating;
private Integer ratingCount; private Integer ratingCount;

View File

@@ -22,4 +22,5 @@ public class UserInfo {
private String createdAt; private String createdAt;
private int favoriteCount; private int favoriteCount;
private int reviewCount; private int reviewCount;
private int memoCount;
} }

View File

@@ -21,4 +21,9 @@ public interface ChannelMapper {
int deactivateById(@Param("id") String id); int deactivateById(@Param("id") String id);
Channel findByChannelId(@Param("channelId") String channelId); Channel findByChannelId(@Param("channelId") String channelId);
void updateChannel(@Param("id") String id,
@Param("description") String description,
@Param("tags") String tags,
@Param("sortOrder") Integer sortOrder);
} }

View File

@@ -0,0 +1,32 @@
package com.tasteby.mapper;
import com.tasteby.domain.Memo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface MemoMapper {
Memo findByUserAndRestaurant(@Param("userId") String userId,
@Param("restaurantId") String restaurantId);
void insertMemo(@Param("id") String id,
@Param("userId") String userId,
@Param("restaurantId") String restaurantId,
@Param("rating") Double rating,
@Param("memoText") String memoText,
@Param("visitedAt") String visitedAt);
int updateMemo(@Param("userId") String userId,
@Param("restaurantId") String restaurantId,
@Param("rating") Double rating,
@Param("memoText") String memoText,
@Param("visitedAt") String visitedAt);
int deleteMemo(@Param("userId") String userId,
@Param("restaurantId") String restaurantId);
List<Memo> findByUser(@Param("userId") String userId);
}

View File

@@ -55,6 +55,14 @@ public interface RestaurantMapper {
void updateFoodsMentioned(@Param("id") String id, @Param("foods") String foods); void updateFoodsMentioned(@Param("id") String id, @Param("foods") String foods);
List<Restaurant> findWithoutTabling();
List<Restaurant> findWithoutCatchtable();
void resetTablingUrls();
void resetCatchtableUrls();
List<Map<String, Object>> findForRemapCuisine(); List<Map<String, Object>> findForRemapCuisine();
List<Map<String, Object>> findForRemapFoods(); List<Map<String, Object>> findForRemapFoods();

View File

@@ -68,6 +68,10 @@ public interface VideoMapper {
List<Map<String, Object>> findVideosWithoutTranscript(); List<Map<String, Object>> findVideosWithoutTranscript();
List<Map<String, Object>> findVideosByIds(@Param("ids") List<String> ids);
List<Map<String, Object>> findVideosForExtractByIds(@Param("ids") List<String> ids);
void updateVideoRestaurantFields(@Param("videoId") String videoId, void updateVideoRestaurantFields(@Param("videoId") String videoId,
@Param("restaurantId") String restaurantId, @Param("restaurantId") String restaurantId,
@Param("foodsJson") String foodsJson, @Param("foodsJson") String foodsJson,

View File

@@ -38,4 +38,8 @@ public class ChannelService {
public Channel findByChannelId(String channelId) { public Channel findByChannelId(String channelId) {
return mapper.findByChannelId(channelId); return mapper.findByChannelId(channelId);
} }
public void update(String id, String description, String tags, Integer sortOrder) {
mapper.updateChannel(id, description, tags, sortOrder);
}
} }

View File

@@ -131,6 +131,34 @@ public class GeocodingService {
} }
} }
/**
* Parse Korean address into region format "나라|시/도|구/군".
* Example: "대한민국 서울특별시 강남구 역삼동 123" → "한국|서울|강남구"
*/
public static String parseRegionFromAddress(String address) {
if (address == null || address.isBlank()) return null;
String[] parts = address.split("\\s+");
String country = "";
String city = "";
String district = "";
for (String p : parts) {
if (p.equals("대한민국") || p.equals("South Korea")) {
country = "한국";
} else if (p.endsWith("특별시") || p.endsWith("광역시") || p.endsWith("특별자치시")) {
city = p.replace("특별시", "").replace("광역시", "").replace("특별자치시", "");
} else if (p.endsWith("") && !p.endsWith("") && p.length() <= 5) {
city = p;
} else if (p.endsWith("") || p.endsWith("") || (p.endsWith("") && !city.isEmpty())) {
if (district.isEmpty()) district = p;
}
}
if (country.isEmpty() && !city.isEmpty()) country = "한국";
if (country.isEmpty()) return null;
return country + "|" + city + "|" + district;
}
private Map<String, Object> geocode(String query) { private Map<String, Object> geocode(String query) {
try { try {
String response = webClient.get() String response = webClient.get()

View File

@@ -0,0 +1,44 @@
package com.tasteby.service;
import com.tasteby.domain.Memo;
import com.tasteby.mapper.MemoMapper;
import com.tasteby.util.IdGenerator;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
@Service
public class MemoService {
private final MemoMapper mapper;
public MemoService(MemoMapper mapper) {
this.mapper = mapper;
}
public Memo findByUserAndRestaurant(String userId, String restaurantId) {
return mapper.findByUserAndRestaurant(userId, restaurantId);
}
@Transactional
public Memo upsert(String userId, String restaurantId, Double rating, String memoText, LocalDate visitedAt) {
String visitedStr = visitedAt != null ? visitedAt.toString() : null;
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);
}
return mapper.findByUserAndRestaurant(userId, restaurantId);
}
public boolean delete(String userId, String restaurantId) {
return mapper.deleteMemo(userId, restaurantId) > 0;
}
public List<Memo> findByUser(String userId) {
return mapper.findByUser(userId);
}
}

View File

@@ -16,6 +16,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -45,6 +46,8 @@ public class OciGenAiService {
private final ObjectMapper mapper; private final ObjectMapper mapper;
private ConfigFileAuthenticationDetailsProvider authProvider; private ConfigFileAuthenticationDetailsProvider authProvider;
private GenerativeAiInferenceClient chatClient;
private GenerativeAiInferenceClient embedClient;
public OciGenAiService(ObjectMapper mapper) { public OciGenAiService(ObjectMapper mapper) {
this.mapper = mapper; this.mapper = mapper;
@@ -55,21 +58,27 @@ public class OciGenAiService {
try { try {
ConfigFileReader.ConfigFile configFile = ConfigFileReader.parseDefault(); ConfigFileReader.ConfigFile configFile = ConfigFileReader.parseDefault();
authProvider = new ConfigFileAuthenticationDetailsProvider(configFile); authProvider = new ConfigFileAuthenticationDetailsProvider(configFile);
log.info("OCI GenAI auth configured"); chatClient = GenerativeAiInferenceClient.builder()
.endpoint(chatEndpoint).build(authProvider);
embedClient = GenerativeAiInferenceClient.builder()
.endpoint(embedEndpoint).build(authProvider);
log.info("OCI GenAI auth configured (clients initialized)");
} catch (Exception e) { } catch (Exception e) {
log.warn("OCI config not found, GenAI features disabled: {}", e.getMessage()); log.warn("OCI config not found, GenAI features disabled: {}", e.getMessage());
} }
} }
@PreDestroy
public void destroy() {
if (chatClient != null) chatClient.close();
if (embedClient != null) embedClient.close();
}
/** /**
* Call OCI GenAI LLM (Chat). * Call OCI GenAI LLM (Chat).
*/ */
public String chat(String prompt, int maxTokens) { public String chat(String prompt, int maxTokens) {
if (authProvider == null) throw new IllegalStateException("OCI GenAI not configured"); if (chatClient == null) throw new IllegalStateException("OCI GenAI not configured");
try (var client = GenerativeAiInferenceClient.builder()
.endpoint(chatEndpoint)
.build(authProvider)) {
var textContent = TextContent.builder().text(prompt).build(); var textContent = TextContent.builder().text(prompt).build();
var userMessage = UserMessage.builder().content(List.of(textContent)).build(); var userMessage = UserMessage.builder().content(List.of(textContent)).build();
@@ -86,7 +95,7 @@ public class OciGenAiService {
.chatRequest(chatRequest) .chatRequest(chatRequest)
.build(); .build();
ChatResponse response = client.chat( ChatResponse response = chatClient.chat(
ChatRequest.builder().chatDetails(chatDetails).build()); ChatRequest.builder().chatDetails(chatDetails).build());
var chatResult = (GenericChatResponse) response.getChatResult().getChatResponse(); var chatResult = (GenericChatResponse) response.getChatResult().getChatResponse();
@@ -94,7 +103,6 @@ public class OciGenAiService {
var content = ((TextContent) choice.getMessage().getContent().get(0)).getText(); var content = ((TextContent) choice.getMessage().getContent().get(0)).getText();
return content.trim(); return content.trim();
} }
}
/** /**
* Generate embeddings for a list of texts. * Generate embeddings for a list of texts.
@@ -111,9 +119,7 @@ public class OciGenAiService {
} }
private List<List<Double>> embedBatch(List<String> texts) { private List<List<Double>> embedBatch(List<String> texts) {
try (var client = GenerativeAiInferenceClient.builder() if (embedClient == null) throw new IllegalStateException("OCI GenAI not configured");
.endpoint(embedEndpoint)
.build(authProvider)) {
var embedDetails = EmbedTextDetails.builder() var embedDetails = EmbedTextDetails.builder()
.inputs(texts) .inputs(texts)
@@ -122,7 +128,7 @@ public class OciGenAiService {
.inputType(EmbedTextDetails.InputType.SearchDocument) .inputType(EmbedTextDetails.InputType.SearchDocument)
.build(); .build();
EmbedTextResponse response = client.embedText( EmbedTextResponse response = embedClient.embedText(
EmbedTextRequest.builder().embedTextDetails(embedDetails).build()); EmbedTextRequest.builder().embedTextDetails(embedDetails).build());
return response.getEmbedTextResult().getEmbeddings() return response.getEmbedTextResult().getEmbeddings()
@@ -130,7 +136,6 @@ public class OciGenAiService {
.map(emb -> emb.stream().map(Number::doubleValue).toList()) .map(emb -> emb.stream().map(Number::doubleValue).toList())
.toList(); .toList();
} }
}
/** /**
* Parse LLM response as JSON (handles markdown code blocks, truncated arrays, etc.) * Parse LLM response as JSON (handles markdown code blocks, truncated arrays, etc.)

View File

@@ -26,6 +26,24 @@ public class RestaurantService {
return restaurants; return restaurants;
} }
public List<Restaurant> findWithoutTabling() {
return mapper.findWithoutTabling();
}
public List<Restaurant> findWithoutCatchtable() {
return mapper.findWithoutCatchtable();
}
@Transactional
public void resetTablingUrls() {
mapper.resetTablingUrls();
}
@Transactional
public void resetCatchtableUrls() {
mapper.resetCatchtableUrls();
}
public Restaurant findById(String id) { public Restaurant findById(String id) {
Restaurant restaurant = mapper.findById(id); Restaurant restaurant = mapper.findById(id);
if (restaurant == null) return null; if (restaurant == null) return null;

View File

@@ -111,6 +111,22 @@ public class VideoService {
return rows.stream().map(JsonUtil::lowerKeys).toList(); return rows.stream().map(JsonUtil::lowerKeys).toList();
} }
public List<Map<String, Object>> findVideosByIds(List<String> ids) {
var rows = mapper.findVideosByIds(ids);
return rows.stream().map(JsonUtil::lowerKeys).toList();
}
public List<Map<String, Object>> findVideosForExtractByIds(List<String> ids) {
var rows = mapper.findVideosForExtractByIds(ids);
return rows.stream().map(row -> {
var r = JsonUtil.lowerKeys(row);
Object transcript = r.get("transcript_text");
r.put("transcript", JsonUtil.readClob(transcript));
r.remove("transcript_text");
return r;
}).toList();
}
public void updateVideoRestaurantFields(String videoId, String restaurantId, public void updateVideoRestaurantFields(String videoId, String restaurantId,
String foodsJson, String evaluation, String guestsJson) { String foodsJson, String evaluation, String guestsJson) {
mapper.updateVideoRestaurantFields(videoId, restaurantId, foodsJson, evaluation, guestsJson); mapper.updateVideoRestaurantFields(videoId, restaurantId, foodsJson, evaluation, guestsJson);

View File

@@ -50,10 +50,77 @@ public class YouTubeService {
} }
/** /**
* Fetch videos from a YouTube channel, page by page. * Fetch videos from a YouTube channel using the uploads playlist (UC→UU).
* Returns all pages merged into one list. * This returns ALL videos unlike the Search API which caps results.
* Falls back to Search API if playlist approach fails.
*/ */
public List<Map<String, Object>> fetchChannelVideos(String channelId, String publishedAfter, boolean excludeShorts) { public List<Map<String, Object>> fetchChannelVideos(String channelId, String publishedAfter, boolean excludeShorts) {
// Convert channel ID UC... → uploads playlist UU...
String uploadsPlaylistId = "UU" + channelId.substring(2);
List<Map<String, Object>> allVideos = new ArrayList<>();
String nextPage = null;
try {
do {
String pageToken = nextPage;
String response = webClient.get()
.uri(uriBuilder -> {
var b = uriBuilder.path("/playlistItems")
.queryParam("key", apiKey)
.queryParam("playlistId", uploadsPlaylistId)
.queryParam("part", "snippet")
.queryParam("maxResults", 50);
if (pageToken != null) b.queryParam("pageToken", pageToken);
return b.build();
})
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofSeconds(30));
JsonNode data = mapper.readTree(response);
List<Map<String, Object>> pageVideos = new ArrayList<>();
for (JsonNode item : data.path("items")) {
JsonNode snippet = item.path("snippet");
String vid = snippet.path("resourceId").path("videoId").asText();
String publishedAt = snippet.path("publishedAt").asText();
// publishedAfter 필터: 이미 스캔한 영상 이후만
if (publishedAfter != null && publishedAt.compareTo(publishedAfter) <= 0) {
// 업로드 재생목록은 최신순이므로 이전 날짜 만나면 중단
nextPage = null;
break;
}
pageVideos.add(Map.of(
"video_id", vid,
"title", snippet.path("title").asText(),
"published_at", publishedAt,
"url", "https://www.youtube.com/watch?v=" + vid
));
}
if (excludeShorts && !pageVideos.isEmpty()) {
pageVideos = filterShorts(pageVideos);
}
allVideos.addAll(pageVideos);
if (nextPage != null || data.has("nextPageToken")) {
nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null;
}
} while (nextPage != null);
} catch (Exception e) {
log.warn("PlaylistItems API failed for {}, falling back to Search API", channelId, e);
return fetchChannelVideosViaSearch(channelId, publishedAfter, excludeShorts);
}
return allVideos;
}
/**
* Fallback: fetch via Search API (may not return all videos).
*/
private List<Map<String, Object>> fetchChannelVideosViaSearch(String channelId, String publishedAfter, boolean excludeShorts) {
List<Map<String, Object>> allVideos = new ArrayList<>(); List<Map<String, Object>> allVideos = new ArrayList<>();
String nextPage = null; String nextPage = null;
@@ -98,7 +165,7 @@ public class YouTubeService {
nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null; nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null;
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to parse YouTube API response", e); log.error("Failed to parse YouTube Search API response", e);
break; break;
} }
} while (nextPage != null); } while (nextPage != null);
@@ -108,9 +175,16 @@ public class YouTubeService {
/** /**
* Filter out YouTube Shorts (<=60s duration). * Filter out YouTube Shorts (<=60s duration).
* YouTube /videos API accepts max 50 IDs per request, so we batch.
*/ */
private List<Map<String, Object>> filterShorts(List<Map<String, Object>> videos) { private List<Map<String, Object>> filterShorts(List<Map<String, Object>> videos) {
String ids = String.join(",", videos.stream().map(v -> (String) v.get("video_id")).toList()); Map<String, Integer> durations = new HashMap<>();
List<String> allIds = videos.stream().map(v -> (String) v.get("video_id")).toList();
for (int i = 0; i < allIds.size(); i += 50) {
List<String> batch = allIds.subList(i, Math.min(i + 50, allIds.size()));
String ids = String.join(",", batch);
try {
String response = webClient.get() String response = webClient.get()
.uri(uriBuilder -> uriBuilder.path("/videos") .uri(uriBuilder -> uriBuilder.path("/videos")
.queryParam("key", apiKey) .queryParam("key", apiKey)
@@ -121,22 +195,21 @@ public class YouTubeService {
.bodyToMono(String.class) .bodyToMono(String.class)
.block(Duration.ofSeconds(30)); .block(Duration.ofSeconds(30));
try {
JsonNode data = mapper.readTree(response); JsonNode data = mapper.readTree(response);
Map<String, Integer> durations = new HashMap<>();
for (JsonNode item : data.path("items")) { for (JsonNode item : data.path("items")) {
String duration = item.path("contentDetails").path("duration").asText(); String duration = item.path("contentDetails").path("duration").asText();
durations.put(item.path("id").asText(), parseDuration(duration)); durations.put(item.path("id").asText(), parseDuration(duration));
} }
return videos.stream()
.filter(v -> durations.getOrDefault(v.get("video_id"), 0) > 60)
.toList();
} catch (Exception e) { } catch (Exception e) {
log.warn("Failed to filter shorts", e); log.warn("Failed to fetch video durations for batch starting at {}", i, e);
return videos;
} }
} }
return videos.stream()
.filter(v -> durations.getOrDefault(v.get("video_id"), 61) > 60)
.toList();
}
private int parseDuration(String dur) { private int parseDuration(String dur) {
Matcher m = DURATION_PATTERN.matcher(dur != null ? dur : ""); Matcher m = DURATION_PATTERN.matcher(dur != null ? dur : "");
if (!m.matches()) return 0; if (!m.matches()) return 0;
@@ -208,16 +281,16 @@ public class YouTubeService {
public TranscriptResult getTranscript(String videoId, String mode) { public TranscriptResult getTranscript(String videoId, String mode) {
if (mode == null) mode = "auto"; if (mode == null) mode = "auto";
// 1) Fast path: youtube-transcript-api // 1) Playwright headed browser (봇 판정 회피)
TranscriptResult apiResult = getTranscriptApi(videoId, mode); TranscriptResult browserResult = getTranscriptBrowser(videoId);
if (apiResult != null) return apiResult; if (browserResult != null) return browserResult;
// 2) Fallback: Playwright browser // 2) Fallback: youtube-transcript-api
log.warn("API failed for {}, trying Playwright browser", videoId); log.warn("Browser failed for {}, trying API", videoId);
return getTranscriptBrowser(videoId); return getTranscriptApi(videoId, mode);
} }
private TranscriptResult getTranscriptApi(String videoId, String mode) { public TranscriptResult getTranscriptApi(String videoId, String mode) {
TranscriptList transcriptList; TranscriptList transcriptList;
try { try {
transcriptList = transcriptApi.listTranscripts(videoId); transcriptList = transcriptApi.listTranscripts(videoId);
@@ -262,39 +335,63 @@ public class YouTubeService {
} }
} }
// ─── Playwright browser fallback ─────────────────────────────────────────── // ─── Playwright browser ───────────────────────────────────────────────────
/**
* Fetch transcript using an existing Playwright Page (for bulk reuse).
*/
@SuppressWarnings("unchecked")
public TranscriptResult getTranscriptWithPage(Page page, String videoId) {
return fetchTranscriptFromPage(page, videoId);
}
/**
* Create a Playwright browser + context + page for transcript fetching.
* Caller must close the returned resources (Playwright, Browser).
*/
public record BrowserSession(Playwright playwright, Browser browser, Page page) implements AutoCloseable {
@Override
public void close() {
try { browser.close(); } catch (Exception ignored) {}
try { playwright.close(); } catch (Exception ignored) {}
}
}
public BrowserSession createBrowserSession() {
Playwright pw = Playwright.create();
Browser browser = pw.chromium().launch(new BrowserType.LaunchOptions()
.setHeadless(false)
.setArgs(List.of("--disable-blink-features=AutomationControlled")));
BrowserContext ctx = browser.newContext(new Browser.NewContextOptions()
.setLocale("ko-KR")
.setViewportSize(1280, 900));
loadCookies(ctx);
Page page = ctx.newPage();
page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})");
return new BrowserSession(pw, browser, page);
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private TranscriptResult getTranscriptBrowser(String videoId) { private TranscriptResult getTranscriptBrowser(String videoId) {
try (Playwright pw = Playwright.create()) { try (BrowserSession session = createBrowserSession()) {
BrowserType.LaunchOptions launchOpts = new BrowserType.LaunchOptions() return fetchTranscriptFromPage(session.page(), videoId);
.setHeadless(false) } catch (Exception e) {
.setArgs(List.of("--disable-blink-features=AutomationControlled")); log.error("[TRANSCRIPT] Playwright failed for {}: {}", videoId, e.getMessage());
return null;
try (Browser browser = pw.chromium().launch(launchOpts)) { }
Browser.NewContextOptions ctxOpts = new Browser.NewContextOptions() }
.setLocale("ko-KR")
.setViewportSize(1280, 900);
BrowserContext ctx = browser.newContext(ctxOpts);
// Load YouTube cookies if available
loadCookies(ctx);
Page page = ctx.newPage();
// Hide webdriver flag to reduce bot detection
page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})");
@SuppressWarnings("unchecked")
private TranscriptResult fetchTranscriptFromPage(Page page, String videoId) {
try {
log.info("[TRANSCRIPT] Opening YouTube page for {}", videoId); log.info("[TRANSCRIPT] Opening YouTube page for {}", videoId);
page.navigate("https://www.youtube.com/watch?v=" + videoId, page.navigate("https://www.youtube.com/watch?v=" + videoId,
new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(30000)); new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(30000));
page.waitForTimeout(5000); page.waitForTimeout(3000);
// Skip ads if present
skipAds(page); skipAds(page);
page.waitForTimeout(2000); page.waitForTimeout(1000);
log.info("[TRANSCRIPT] Page loaded, looking for transcript button"); log.info("[TRANSCRIPT] Page loaded, looking for transcript button");
// Click "더보기" (expand description) // Click "더보기" (expand description)
@@ -348,18 +445,17 @@ public class YouTubeService {
return null; return null;
} }
// Wait for transcript segments to appear (max ~40s) // Wait for transcript segments to appear (max ~15s)
page.waitForTimeout(3000); page.waitForTimeout(2000);
for (int attempt = 0; attempt < 12; attempt++) { for (int attempt = 0; attempt < 10; attempt++) {
page.waitForTimeout(3000); page.waitForTimeout(1500);
Object count = page.evaluate( Object count = page.evaluate(
"() => document.querySelectorAll('ytd-transcript-segment-renderer').length"); "() => document.querySelectorAll('ytd-transcript-segment-renderer').length");
int segCount = count instanceof Number n ? n.intValue() : 0; int segCount = count instanceof Number n ? n.intValue() : 0;
log.info("[TRANSCRIPT] Wait {}s: {} segments", (attempt + 1) * 3 + 3, segCount); log.info("[TRANSCRIPT] Wait {}s: {} segments", (attempt + 1) * 1.5 + 2, segCount);
if (segCount > 0) break; if (segCount > 0) break;
} }
// Select Korean if available
selectKorean(page); selectKorean(page);
// Scroll transcript panel and collect segments // Scroll transcript panel and collect segments
@@ -404,21 +500,30 @@ public class YouTubeService {
log.warn("[TRANSCRIPT] No segments found via browser for {}", videoId); log.warn("[TRANSCRIPT] No segments found via browser for {}", videoId);
return null; return null;
}
} catch (Exception e) { } catch (Exception e) {
log.error("[TRANSCRIPT] Playwright failed for {}: {}", videoId, e.getMessage()); log.error("[TRANSCRIPT] Page fetch failed for {}: {}", videoId, e.getMessage());
return null; return null;
} }
} }
private void skipAds(Page page) { private void skipAds(Page page) {
for (int i = 0; i < 12; i++) { for (int i = 0; i < 30; i++) {
Object adStatus = page.evaluate(""" Object adStatus = page.evaluate("""
() => { () => {
const skipBtn = document.querySelector('.ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern, button.ytp-ad-skip-button-modern'); const skipBtn = document.querySelector('.ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern, button.ytp-ad-skip-button-modern');
if (skipBtn) { skipBtn.click(); return 'skipped'; } if (skipBtn) { skipBtn.click(); return 'skipped'; }
const adOverlay = document.querySelector('.ytp-ad-player-overlay, .ad-showing'); const adOverlay = document.querySelector('.ytp-ad-player-overlay, .ad-showing');
if (adOverlay) return 'playing'; if (adOverlay) {
// 광고 중: 뮤트 + 끝으로 이동 시도
const video = document.querySelector('video');
if (video) {
video.muted = true;
if (video.duration && isFinite(video.duration)) {
video.currentTime = video.duration;
}
}
return 'playing';
}
const adBadge = document.querySelector('.ytp-ad-text'); const adBadge = document.querySelector('.ytp-ad-text');
if (adBadge && adBadge.textContent) return 'badge'; if (adBadge && adBadge.textContent) return 'badge';
return 'none'; return 'none';
@@ -428,10 +533,10 @@ public class YouTubeService {
if ("none".equals(status)) break; if ("none".equals(status)) break;
log.info("[TRANSCRIPT] Ad detected: {}, waiting...", status); log.info("[TRANSCRIPT] Ad detected: {}, waiting...", status);
if ("skipped".equals(status)) { if ("skipped".equals(status)) {
page.waitForTimeout(2000); page.waitForTimeout(1000);
break; break;
} }
page.waitForTimeout(5000); page.waitForTimeout(1000);
} }
} }

View File

@@ -39,7 +39,7 @@ app:
expiration-days: 7 expiration-days: 7
cors: cors:
allowed-origins: http://localhost:3000,http://localhost:3001,https://www.tasteby.net,https://tasteby.net allowed-origins: http://localhost:3000,http://localhost:3001,https://www.tasteby.net,https://tasteby.net,https://dev.tasteby.net
oracle: oracle:
wallet-path: ${ORACLE_WALLET:} wallet-path: ${ORACLE_WALLET:}

View File

@@ -7,17 +7,20 @@
<result property="channelId" column="channel_id"/> <result property="channelId" column="channel_id"/>
<result property="channelName" column="channel_name"/> <result property="channelName" column="channel_name"/>
<result property="titleFilter" column="title_filter"/> <result property="titleFilter" column="title_filter"/>
<result property="description" column="description"/>
<result property="tags" column="tags"/>
<result property="sortOrder" column="sort_order"/>
<result property="videoCount" column="video_count"/> <result property="videoCount" column="video_count"/>
<result property="lastVideoAt" column="last_video_at"/> <result property="lastVideoAt" column="last_video_at"/>
</resultMap> </resultMap>
<select id="findAllActive" resultMap="channelResultMap"> <select id="findAllActive" resultMap="channelResultMap">
SELECT c.id, c.channel_id, c.channel_name, c.title_filter, SELECT c.id, c.channel_id, c.channel_name, c.title_filter, c.description, c.tags, c.sort_order,
(SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id) AS video_count, (SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id) AS video_count,
(SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_video_at (SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_video_at
FROM channels c FROM channels c
WHERE c.is_active = 1 WHERE c.is_active = 1
ORDER BY c.channel_name ORDER BY c.sort_order, c.channel_name
</select> </select>
<insert id="insert"> <insert id="insert">
@@ -35,6 +38,11 @@
WHERE id = #{id} AND is_active = 1 WHERE id = #{id} AND is_active = 1
</update> </update>
<update id="updateChannel">
UPDATE channels SET description = #{description}, tags = #{tags}, sort_order = #{sortOrder}
WHERE id = #{id}
</update>
<select id="findByChannelId" resultMap="channelResultMap"> <select id="findByChannelId" resultMap="channelResultMap">
SELECT id, channel_id, channel_name, title_filter SELECT id, channel_id, channel_name, title_filter
FROM channels FROM channels

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tasteby.mapper.MemoMapper">
<resultMap id="memoResultMap" type="com.tasteby.domain.Memo">
<id property="id" column="id"/>
<result property="userId" column="user_id"/>
<result property="restaurantId" column="restaurant_id"/>
<result property="rating" column="rating"/>
<result property="memoText" column="memo_text" typeHandler="com.tasteby.config.ClobTypeHandler"/>
<result property="visitedAt" column="visited_at"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
<result property="restaurantName" column="restaurant_name"/>
</resultMap>
<select id="findByUserAndRestaurant" resultMap="memoResultMap">
SELECT id, user_id, restaurant_id, rating, memo_text,
visited_at, created_at, updated_at
FROM user_memos
WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
</select>
<insert id="insertMemo">
INSERT INTO user_memos (id, user_id, restaurant_id, rating, memo_text, visited_at)
VALUES (#{id}, #{userId}, #{restaurantId}, #{rating}, #{memoText},
<choose>
<when test="visitedAt != null">TO_DATE(#{visitedAt}, 'YYYY-MM-DD')</when>
<otherwise>NULL</otherwise>
</choose>)
</insert>
<update id="updateMemo">
UPDATE user_memos SET
rating = #{rating},
memo_text = #{memoText},
visited_at = <choose>
<when test="visitedAt != null">TO_DATE(#{visitedAt}, 'YYYY-MM-DD')</when>
<otherwise>NULL</otherwise>
</choose>,
updated_at = SYSTIMESTAMP
WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
</update>
<delete id="deleteMemo">
DELETE FROM user_memos WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
</delete>
<select id="findByUser" resultMap="memoResultMap">
SELECT m.id, m.user_id, m.restaurant_id, m.rating, m.memo_text,
m.visited_at, m.created_at, m.updated_at,
r.name AS restaurant_name
FROM user_memos m
LEFT JOIN restaurants r ON r.id = m.restaurant_id
WHERE m.user_id = #{userId}
ORDER BY m.updated_at DESC
</select>
</mapper>

View File

@@ -16,6 +16,8 @@
<result property="phone" column="phone"/> <result property="phone" column="phone"/>
<result property="website" column="website"/> <result property="website" column="website"/>
<result property="googlePlaceId" column="google_place_id"/> <result property="googlePlaceId" column="google_place_id"/>
<result property="tablingUrl" column="tabling_url"/>
<result property="catchtableUrl" column="catchtable_url"/>
<result property="businessStatus" column="business_status"/> <result property="businessStatus" column="business_status"/>
<result property="rating" column="rating"/> <result property="rating" column="rating"/>
<result property="ratingCount" column="rating_count"/> <result property="ratingCount" column="rating_count"/>
@@ -26,7 +28,7 @@
<select id="findAll" resultMap="restaurantMap"> <select id="findAll" resultMap="restaurantMap">
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude, SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
r.cuisine_type, r.price_range, r.google_place_id, r.cuisine_type, r.price_range, r.google_place_id, r.tabling_url, r.catchtable_url,
r.business_status, r.rating, r.rating_count, r.updated_at r.business_status, r.rating, r.rating_count, r.updated_at
FROM restaurants r FROM restaurants r
<if test="channel != null and channel != ''"> <if test="channel != null and channel != ''">
@@ -54,7 +56,7 @@
<select id="findById" resultMap="restaurantMap"> <select id="findById" resultMap="restaurantMap">
SELECT r.id, r.name, r.address, r.region, r.latitude, r.longitude, SELECT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
r.cuisine_type, r.price_range, r.phone, r.website, r.google_place_id, r.cuisine_type, r.price_range, r.phone, r.website, r.google_place_id,
r.business_status, r.rating, r.rating_count r.tabling_url, r.catchtable_url, r.business_status, r.rating, r.rating_count
FROM restaurants r FROM restaurants r
WHERE r.id = #{id} WHERE r.id = #{id}
</select> </select>
@@ -129,12 +131,30 @@
<if test="fields.containsKey('website')"> <if test="fields.containsKey('website')">
website = #{fields.website}, website = #{fields.website},
</if> </if>
<if test="fields.containsKey('tabling_url')">
tabling_url = #{fields.tabling_url},
</if>
<if test="fields.containsKey('catchtable_url')">
catchtable_url = #{fields.catchtable_url},
</if>
<if test="fields.containsKey('latitude')"> <if test="fields.containsKey('latitude')">
latitude = #{fields.latitude}, latitude = #{fields.latitude},
</if> </if>
<if test="fields.containsKey('longitude')"> <if test="fields.containsKey('longitude')">
longitude = #{fields.longitude}, longitude = #{fields.longitude},
</if> </if>
<if test="fields.containsKey('google_place_id')">
google_place_id = #{fields.google_place_id},
</if>
<if test="fields.containsKey('business_status')">
business_status = #{fields.business_status},
</if>
<if test="fields.containsKey('rating')">
rating = #{fields.rating},
</if>
<if test="fields.containsKey('rating_count')">
rating_count = #{fields.rating_count},
</if>
updated_at = SYSTIMESTAMP, updated_at = SYSTIMESTAMP,
</trim> </trim>
WHERE id = #{id} WHERE id = #{id}
@@ -201,6 +221,32 @@
</foreach> </foreach>
</select> </select>
<select id="findWithoutTabling" resultMap="restaurantMap">
SELECT r.id, r.name, r.address, r.region
FROM restaurants r
WHERE r.tabling_url IS NULL
AND r.latitude IS NOT NULL
AND EXISTS (SELECT 1 FROM video_restaurants vr WHERE vr.restaurant_id = r.id)
ORDER BY r.name
</select>
<select id="findWithoutCatchtable" resultMap="restaurantMap">
SELECT r.id, r.name, r.address, r.region
FROM restaurants r
WHERE r.catchtable_url IS NULL
AND r.latitude IS NOT NULL
AND EXISTS (SELECT 1 FROM video_restaurants vr WHERE vr.restaurant_id = r.id)
ORDER BY r.name
</select>
<update id="resetTablingUrls">
UPDATE restaurants SET tabling_url = NULL WHERE tabling_url IS NOT NULL
</update>
<update id="resetCatchtableUrls">
UPDATE restaurants SET catchtable_url = NULL WHERE catchtable_url IS NOT NULL
</update>
<!-- ===== Remap operations ===== --> <!-- ===== Remap operations ===== -->
<update id="updateCuisineType"> <update id="updateCuisineType">

View File

@@ -11,7 +11,11 @@
<result property="longitude" column="longitude"/> <result property="longitude" column="longitude"/>
<result property="cuisineType" column="cuisine_type"/> <result property="cuisineType" column="cuisine_type"/>
<result property="priceRange" column="price_range"/> <result property="priceRange" column="price_range"/>
<result property="phone" column="phone"/>
<result property="website" column="website"/>
<result property="googlePlaceId" column="google_place_id"/> <result property="googlePlaceId" column="google_place_id"/>
<result property="tablingUrl" column="tabling_url"/>
<result property="catchtableUrl" column="catchtable_url"/>
<result property="businessStatus" column="business_status"/> <result property="businessStatus" column="business_status"/>
<result property="rating" column="rating"/> <result property="rating" column="rating"/>
<result property="ratingCount" column="rating_count"/> <result property="ratingCount" column="rating_count"/>
@@ -19,7 +23,8 @@
<select id="keywordSearch" resultMap="restaurantMap"> <select id="keywordSearch" resultMap="restaurantMap">
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude, SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
r.cuisine_type, r.price_range, r.google_place_id, r.cuisine_type, r.price_range, r.phone, r.website, r.google_place_id,
r.tabling_url, r.catchtable_url,
r.business_status, r.rating, r.rating_count r.business_status, r.rating, r.rating_count
FROM restaurants r FROM restaurants r
JOIN video_restaurants vr ON vr.restaurant_id = r.id JOIN video_restaurants vr ON vr.restaurant_id = r.id

View File

@@ -12,6 +12,7 @@
<result property="createdAt" column="created_at"/> <result property="createdAt" column="created_at"/>
<result property="favoriteCount" column="favorite_count"/> <result property="favoriteCount" column="favorite_count"/>
<result property="reviewCount" column="review_count"/> <result property="reviewCount" column="review_count"/>
<result property="memoCount" column="memo_count"/>
</resultMap> </resultMap>
<select id="findByProviderAndProviderId" resultMap="userResultMap"> <select id="findByProviderAndProviderId" resultMap="userResultMap">
@@ -38,10 +39,12 @@
<select id="findAllWithCounts" resultMap="userResultMap"> <select id="findAllWithCounts" resultMap="userResultMap">
SELECT u.id, u.email, u.nickname, u.avatar_url, u.provider, u.created_at, SELECT u.id, u.email, u.nickname, u.avatar_url, u.provider, u.created_at,
NVL(fav.cnt, 0) AS favorite_count, NVL(fav.cnt, 0) AS favorite_count,
NVL(rev.cnt, 0) AS review_count NVL(rev.cnt, 0) AS review_count,
NVL(memo.cnt, 0) AS memo_count
FROM tasteby_users u FROM tasteby_users u
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_favorites GROUP BY user_id) fav ON fav.user_id = u.id LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_favorites GROUP BY user_id) fav ON fav.user_id = u.id
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_reviews GROUP BY user_id) rev ON rev.user_id = u.id LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_reviews GROUP BY user_id) rev ON rev.user_id = u.id
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_memos GROUP BY user_id) memo ON memo.user_id = u.id
ORDER BY u.created_at DESC ORDER BY u.created_at DESC
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
</select> </select>

View File

@@ -186,7 +186,8 @@
<insert id="insertVideo"> <insert id="insertVideo">
INSERT INTO videos (id, channel_id, video_id, title, url, published_at) INSERT INTO videos (id, channel_id, video_id, title, url, published_at)
VALUES (#{id}, #{channelId}, #{videoId}, #{title}, #{url}, #{publishedAt}) VALUES (#{id}, #{channelId}, #{videoId}, #{title}, #{url},
TO_TIMESTAMP(#{publishedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
</insert> </insert>
<select id="getExistingVideoIds" resultType="string"> <select id="getExistingVideoIds" resultType="string">
@@ -194,7 +195,7 @@
</select> </select>
<select id="getLatestVideoDate" resultType="string"> <select id="getLatestVideoDate" resultType="string">
SELECT TO_CHAR(MAX(published_at), 'YYYY-MM-DD"T"HH24:MI:SS"Z"') SELECT TO_CHAR(MAX(published_at), 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS latest_date
FROM videos WHERE channel_id = #{channelId} FROM videos WHERE channel_id = #{channelId}
</select> </select>
@@ -220,10 +221,30 @@
SELECT id, video_id, title, url SELECT id, video_id, title, url
FROM videos FROM videos
WHERE (transcript_text IS NULL OR dbms_lob.getlength(transcript_text) = 0) WHERE (transcript_text IS NULL OR dbms_lob.getlength(transcript_text) = 0)
AND status != 'skip' AND status NOT IN ('skip', 'no_transcript')
ORDER BY created_at ORDER BY created_at
</select> </select>
<select id="findVideosByIds" resultType="map">
SELECT id, video_id, title, url
FROM videos
WHERE id IN
<foreach item="id" collection="ids" open="(" separator="," close=")">
#{id}
</foreach>
ORDER BY created_at
</select>
<select id="findVideosForExtractByIds" resultType="map">
SELECT v.id, v.video_id, v.title, v.url, v.transcript_text
FROM videos v
WHERE v.id IN
<foreach item="id" collection="ids" open="(" separator="," close=")">
#{id}
</foreach>
ORDER BY v.published_at DESC
</select>
<update id="updateVideoRestaurantFields"> <update id="updateVideoRestaurantFields">
UPDATE video_restaurants UPDATE video_restaurants
SET foods_mentioned = #{foodsJson,jdbcType=CLOB}, SET foods_mentioned = #{foodsJson,jdbcType=CLOB},

View File

@@ -5,5 +5,6 @@
<setting name="mapUnderscoreToCamelCase" value="true"/> <setting name="mapUnderscoreToCamelCase" value="true"/>
<setting name="callSettersOnNulls" value="true"/> <setting name="callSettersOnNulls" value="true"/>
<setting name="returnInstanceForEmptyRow" value="true"/> <setting name="returnInstanceForEmptyRow" value="true"/>
<setting name="jdbcTypeForNull" value="VARCHAR"/>
</settings> </settings>
</configuration> </configuration>

61
build_spec.yaml Normal file
View File

@@ -0,0 +1,61 @@
version: 0.1
component: build
timeoutInSeconds: 1800
runAs: root
shell: bash
env:
variables:
REGISTRY: "icn.ocir.io/idyhsdamac8c/tasteby"
exportedVariables:
- IMAGE_TAG
- BACKEND_IMAGE
- FRONTEND_IMAGE
steps:
- type: Command
name: "Setup buildx for ARM64"
command: |
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker buildx create --name armbuilder --use
docker buildx inspect --bootstrap
- type: Command
name: "Set image tag"
command: |
IMAGE_TAG="${OCI_BUILD_RUN_ID:0:8}-$(date +%Y%m%d%H%M)"
BACKEND_IMAGE="${REGISTRY}/backend:${IMAGE_TAG}"
FRONTEND_IMAGE="${REGISTRY}/frontend:${IMAGE_TAG}"
echo "IMAGE_TAG=${IMAGE_TAG}"
echo "BACKEND_IMAGE=${BACKEND_IMAGE}"
echo "FRONTEND_IMAGE=${FRONTEND_IMAGE}"
- type: Command
name: "Build backend image"
command: |
cd backend-java
docker buildx build --platform linux/arm64 \
-t "${BACKEND_IMAGE}" \
-t "${REGISTRY}/backend:latest" \
--load \
.
- type: Command
name: "Build frontend image"
command: |
cd frontend
docker buildx build --platform linux/arm64 \
--build-arg NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="${NEXT_PUBLIC_GOOGLE_MAPS_API_KEY}" \
--build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID="${NEXT_PUBLIC_GOOGLE_CLIENT_ID}" \
-t "${FRONTEND_IMAGE}" \
-t "${REGISTRY}/frontend:latest" \
--load \
.
outputArtifacts:
- name: backend-image
type: DOCKER_IMAGE
location: ${BACKEND_IMAGE}
- name: frontend-image
type: DOCKER_IMAGE
location: ${FRONTEND_IMAGE}

405
docs/cicd-architecture.md Normal file
View File

@@ -0,0 +1,405 @@
# Tasteby CI/CD 파이프라인 & 전체 아키텍처
## 전체 시스템 아키텍처
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Internet │
│ www.tasteby.net │
└──────────────────────────────┬──────────────────────────────────────────┘
┌─────────▼──────────┐
│ Namecheap DNS │
│ A → LB External IP │
└─────────┬──────────┘
┌──────────────────────────────▼──────────────────────────────────────────┐
│ OCI Load Balancer (NLB) │
│ (Nginx Ingress Controller가 자동 생성) │
└──────────────────────────────┬──────────────────────────────────────────┘
┌──────────────────────────────▼──────────────────────────────────────────┐
│ OKE Cluster (tasteby-cluster) │
│ ap-seoul-1 │ ARM64 × 2 노드 (2CPU/8GB 각) │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Nginx Ingress Controller (ingress-nginx) │ │
│ │ │ │
│ │ TLS termination (Let's Encrypt via cert-manager) │ │
│ │ tasteby.net → www.tasteby.net 리다이렉트 │ │
│ │ │ │
│ │ /api/* ──→ backend Service :8000 │ │
│ │ /* ──→ frontend Service :3001 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─── namespace: tasteby ─────────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌──────────────────┐ ┌──────────────────┐ ┌────────────────┐ │ │
│ │ │ backend (×1) │ │ frontend (×1) │ │ redis (×1) │ │ │
│ │ │ Spring Boot 3 │ │ Next.js 15 │ │ Redis 7 │ │ │
│ │ │ Java 21 │ │ standalone mode │ │ alpine │ │ │
│ │ │ :8000 │ │ :3001 │ │ :6379 │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ ┌─ Volumes ─────┐│ └──────────────────┘ └────────────────┘ │ │
│ │ │ │ oracle-wallet ││ │ │
│ │ │ │ oci-config ││ │ │
│ │ │ └───────────────┘│ │ │
│ │ └──────────────────┘ │ │
│ │ │ │
│ │ ┌─── ConfigMap / Secrets ──────────────────────────────────────┐ │ │
│ │ │ tasteby-config : REDIS_HOST, OCI endpoints, 등 │ │ │
│ │ │ tasteby-secrets : DB credentials, API keys, JWT │ │ │
│ │ │ oracle-wallet : cwallet.sso, tnsnames.ora, keystore.jks │ │ │
│ │ │ oci-config : OCI API config + PEM key │ │ │
│ │ │ ocir-secret : OCIR 이미지 Pull 인증 │ │ │
│ │ └──────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─── namespace: cert-manager ──┐ ┌─── namespace: ingress-nginx ──┐ │
│ │ cert-manager (×3 pods) │ │ ingress-nginx-controller │ │
│ │ ClusterIssuer: letsencrypt │ │ (NLB 자동 생성) │ │
│ └──────────────────────────────┘ └────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────▼──────────┐
│ Oracle ADB 23ai │
│ (mTLS via Wallet) │
└────────────────────┘
```
## 외부 서비스 연동
```
backend (Spring Boot)
├─→ Oracle ADB 23ai : JDBC + mTLS (Wallet)
├─→ OCI GenAI (Chat) : 식당 정보 추출, 음식 태그, 평가 생성
├─→ OCI GenAI (Embed) : 벡터 임베딩 (Cohere embed-v4)
├─→ Google Maps Places API : 식당 검색, 좌표 조회
├─→ YouTube Data API v3 : 채널/영상 정보 조회
└─→ Redis : 캐시 (API 응답, 검색 결과)
frontend (Next.js)
├─→ Google Maps JavaScript API : 지도 렌더링
└─→ Google OAuth 2.0 : 사용자 인증
```
---
## CI/CD 파이프라인
### 파이프라인 개요
```
┌──────────┐ ┌──────────────────┐ ┌──────────────────────┐ ┌─────────┐
│ 개발자 │────→│ OCI Code Repo │────→│ OCI DevOps Build │────→│ OKE │
│ git push │ │ (tasteby) │ │ Pipeline │ │ 배포 │
└──────────┘ └──────────────────┘ └──────────────────────┘ └─────────┘
```
### 상세 흐름
```
1. 코드 푸시
┌──────────────┐
│ git push oci │ ← OCI Code Repository remote
└──────┬───────┘
2. 빌드 파이프라인 (OCI DevOps Build Pipeline)
┌───────────────────────────────────────────────────────────┐
│ │
│ Stage 1: Managed Build │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ build_spec.yaml 실행 │ │
│ │ │ │
│ │ 1) IMAGE_TAG 생성 (BuildRunID + timestamp) │ │
│ │ 2) docker build --platform linux/arm64 │ │
│ │ - backend-java/Dockerfile → backend image │ │
│ │ - frontend/Dockerfile → frontend image │ │
│ │ 3) Output: BACKEND_IMAGE, FRONTEND_IMAGE │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Stage 2: Deliver Artifacts │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Docker images → OCIR 푸시 │ │
│ │ │ │
│ │ icn.ocir.io/idyhsdamac8c/tasteby/backend:TAG │ │
│ │ icn.ocir.io/idyhsdamac8c/tasteby/frontend:TAG │ │
│ └──────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘
3. 배포 (수동 또는 deploy.sh)
┌───────────────────────────────────────────────────────────┐
│ kubectl set image deployment/backend backend=IMAGE:TAG │
│ kubectl set image deployment/frontend frontend=IMAGE:TAG │
│ kubectl rollout status ... │
│ │
│ git tag -a "vX.Y.Z" -m "Deploy: ..." │
│ git push origin "vX.Y.Z" │
└───────────────────────────────────────────────────────────┘
```
### Git Remote 구성
```
origin → Gitea (gittea.cloud-handson.com) ← 소스 코드 관리
oci → OCI Code Repository ← CI/CD 트리거용
```
두 리모트에 모두 push하여 소스와 빌드를 동기화합니다.
### OCI IAM 권한 설정 (빌드/배포용)
OCI DevOps Build Pipeline이 코드 레포, OCIR, 시크릿 등에 접근하려면 **Dynamic Group**과 **IAM Policy**가 필요합니다.
#### Dynamic Group
| 이름 | 설명 |
|------|------|
| `tasteby-build-pipeline` | DevOps 빌드/배포 파이프라인 리소스 |
**Matching Rule:**
```
ANY {
resource.type = 'devopsbuildpipeline',
resource.type = 'devopsrepository',
resource.type = 'devopsdeploypipeline',
resource.type = 'devopsconnection'
}
```
#### IAM Policy
| 이름 | 설명 |
|------|------|
| `tasteby-devops-policy` | DevOps 파이프라인 리소스 접근 권한 |
**Policy Statements:**
```
Allow dynamic-group tasteby-build-pipeline to manage devops-family in tenancy
Allow dynamic-group tasteby-build-pipeline to manage repos in tenancy
Allow dynamic-group tasteby-build-pipeline to read secret-family in tenancy
Allow dynamic-group tasteby-build-pipeline to manage generic-artifacts in tenancy
Allow dynamic-group tasteby-build-pipeline to use ons-topics in tenancy
```
> **참고**: IAM 정책은 적용 후 전파에 최대 수 분이 걸릴 수 있습니다.
> 빌드 실행 시 `RelatedResourceNotAuthorizedOrNotFound` 오류가 나면 정책 전파를 기다린 후 재시도하세요.
#### OCI Code Repository 인증 (HTTPS)
```
Username: <tenancy-name>/oracleidentitycloudservice/<oci-username>
Password: OCI Auth Token (User Settings에서 생성)
```
```bash
# Git remote 추가 예시
git remote add oci https://devops.scmservice.ap-seoul-1.oci.oraclecloud.com/namespaces/<namespace>/projects/tasteby/repositories/tasteby
```
### build_spec.yaml 구조
```yaml
# OCI DevOps Build Pipeline 설정
version: 0.1
component: build
shell: bash
env:
variables:
REGISTRY: "icn.ocir.io/idyhsdamac8c/tasteby"
exportedVariables:
- IMAGE_TAG # 다음 stage에서 사용
- BACKEND_IMAGE
- FRONTEND_IMAGE
steps:
- Set image tag # BuildRunID + timestamp 조합
- Build backend image # backend-java/Dockerfile, ARM64
- Build frontend image # frontend/Dockerfile, ARM64
outputArtifacts:
- backend-image (DOCKER_IMAGE)
- frontend-image (DOCKER_IMAGE)
```
---
## 로컬 배포 (deploy.sh)
OCI DevOps 없이 로컬에서 직접 배포할 때 사용합니다.
```bash
# 전체 배포
./deploy.sh "초기 배포"
# 백엔드만
./deploy.sh --backend-only "API 버그 수정"
# 프론트엔드만
./deploy.sh --frontend-only "UI 개선"
# 드라이런
./deploy.sh --dry-run "테스트"
```
**deploy.sh 동작:**
1. 최신 git tag에서 다음 버전 계산 (v0.1.X → v0.1.X+1)
2. Docker build (ARM64) + OCIR push
3. `kubectl set image` → rollout 대기
4. git tag 생성 + push
---
## Docker 이미지 빌드
### Backend (Spring Boot)
```
eclipse-temurin:21-jdk (build)
└─ gradlew bootJar
└─ app.jar
eclipse-temurin:21-jre (runtime)
└─ java -XX:MaxRAMPercentage=75.0 -jar app.jar
└─ EXPOSE 8000
```
### Frontend (Next.js)
```
node:22-alpine (build)
└─ npm ci + npm run build
└─ NEXT_PUBLIC_* 빌드 시 주입 (build args)
node:22-alpine (runtime)
└─ .next/standalone + .next/static + public/
└─ node server.js
└─ EXPOSE 3001
```
---
## K8s 리소스 구성
### 파일 구조
```
k8s/
├── namespace.yaml # tasteby namespace
├── configmap.yaml # 비밀이 아닌 설정
├── secrets.yaml.template # 시크릿 템플릿 (실제 파일은 .gitignore)
├── redis-deployment.yaml # Redis 7 + Service
├── backend-deployment.yaml # Spring Boot + Service
├── frontend-deployment.yaml # Next.js + Service
├── ingress.yaml # Nginx Ingress + TLS
└── cert-manager/
└── cluster-issuer.yaml # Let's Encrypt ClusterIssuer
```
### 리소스 할당
| Pod | replicas | CPU req/lim | Memory req/lim |
|-----|----------|-------------|----------------|
| backend | 1 | 500m / 1 | 768Mi / 1536Mi |
| frontend | 1 | 200m / 500m | 256Mi / 512Mi |
| redis | 1 | 100m / 200m | 128Mi / 256Mi |
| ingress-controller | 1 | 100m / 200m | 128Mi / 256Mi |
| cert-manager (×3) | 1 each | 50m / 100m | 64Mi / 128Mi |
| **합계** | | **~1.2 CPU** | **~1.6GB** |
클러스터: ARM64 × 2 노드 (4 CPU / 16GB 총) → 여유 충분
### 네트워크 흐름
```
Client → NLB:443 → Ingress Controller → /api/* → backend:8000
→ /* → frontend:3001
backend → redis:6379 (K8s Service DNS, 클러스터 내부)
backend → Oracle ADB (mTLS, Wallet Volume Mount)
backend → OCI GenAI (OCI SDK, oci-config Volume Mount)
backend → Google APIs (API Key, 환경변수)
```
### Volume Mounts (backend)
```
/etc/oracle/wallet/ ← Secret: oracle-wallet
├── cwallet.sso
├── tnsnames.ora
├── sqlnet.ora
├── keystore.jks
├── truststore.jks
└── ojdbc.properties
/root/.oci/ ← Secret: oci-config
├── config
└── oci_api_key.pem
```
---
## 환경 분리
```
┌────────────────────────────┬──────────────────────────────────────┐
│ 개발 (Local) │ 운영 (OKE) │
├────────────────────────────┼──────────────────────────────────────┤
│ backend/.env │ ConfigMap + Secret │
│ frontend/.env.local │ Dockerfile build args │
│ ~/.oci/config │ Secret: oci-config → /root/.oci/ │
│ 로컬 Wallet 디렉토리 │ Secret: oracle-wallet → /etc/oracle/ │
│ Redis: 192.168.0.147:6379 │ Redis: redis:6379 (K8s DNS) │
│ PM2로 프로세스 관리 │ K8s Deployment로 관리 │
│ nginx + certbot (SSL) │ Ingress + cert-manager (SSL) │
└────────────────────────────┴──────────────────────────────────────┘
```
**변수명이 동일**하므로 코드 변경 없이 환경만 교체 가능합니다.
자세한 환경변수 목록은 [environment-guide.md](./environment-guide.md) 참고.
---
## OCI 리소스 정보
| 항목 | 값 |
|------|-----|
| 리전 | ap-seoul-1 (Seoul) |
| OCI 프로필 | JOUNGMINKOAWS |
| OKE 클러스터 | tasteby-cluster |
| OCIR Registry | icn.ocir.io/idyhsdamac8c/tasteby |
| DevOps 프로젝트 | tasteby |
| Code Repository | tasteby (OCI DevOps SCM) |
| 도메인 | www.tasteby.net (Namecheap) |
| SSL | Let's Encrypt (cert-manager HTTP-01) |
| 노드 | ARM64 × 2대 (2 CPU / 8GB 각) |
---
## 배포 버전 관리
- 태그 형식: `v0.1.X` (patch 자동 증가)
- deploy.sh가 자동으로 git tag 생성 + push
- 태그 메시지에 배포 대상(backend/frontend)과 이미지 태그 포함
```bash
# 태그 목록 확인
git tag -l 'v*' --sort=-v:refname
# 특정 태그 상세 확인
git tag -n20 v0.1.5
# 롤백
kubectl rollout undo deployment/backend -n tasteby
```
---
## 관련 문서
- [OKE 배포 가이드](./oke-deployment-guide.md) — 인프라 설치 및 배포 절차
- [환경 관리 가이드](./environment-guide.md) — 환경변수 및 시크릿 관리

262
docs/deployment-guide.md Normal file
View File

@@ -0,0 +1,262 @@
# Tasteby 배포 가이드
## 환경 요약
| 항목 | Dev (개발) | Prod (운영) |
|------|-----------|-------------|
| URL | dev.tasteby.net | www.tasteby.net |
| 호스트 | 로컬 Mac mini | OKE (Oracle Kubernetes Engine) |
| 프로세스 관리 | PM2 | Kubernetes Deployment |
| 프론트엔드 실행 | `npm run dev` (Next.js dev server) | `node server.js` (standalone 빌드) |
| 백엔드 실행 | `./gradlew bootRun` | `java -jar app.jar` (bootJar 빌드) |
| Redis | 로컬 Redis 서버 | K8s Pod (redis:7-alpine) |
| TLS | Nginx(192.168.0.147) + Certbot | cert-manager + Let's Encrypt |
| 리버스 프록시 | Nginx (192.168.0.147 → 192.168.0.208) | Nginx Ingress Controller (K8s) |
| 도메인 DNS | dev.tasteby.net → Mac mini IP | www.tasteby.net → OCI NLB 217.142.131.194 |
---
## 1. Dev 환경 (dev.tasteby.net)
### 구조
```
브라우저 → dev.tasteby.net (HTTPS)
Nginx (192.168.0.147) — Certbot Let's Encrypt TLS
├── /api/* → proxy_pass http://192.168.0.208:8000 (tasteby-api)
└── /* → proxy_pass http://192.168.0.208:3001 (tasteby-web)
Mac mini (192.168.0.208) — PM2 프로세스 매니저
├── tasteby-api → ./gradlew bootRun (:8000)
└── tasteby-web → npm run dev (:3001)
```
- **192.168.0.147**: Nginx 리버스 프록시 서버 (TLS 종료, Certbot 자동 갱신)
- **192.168.0.208**: Mac mini (실제 앱 서버, PM2 관리)
### PM2 프로세스 구성 (ecosystem.config.js)
```javascript
module.exports = {
apps: [
{
name: "tasteby-api",
cwd: "/Users/joungmin/workspaces/tasteby/backend-java",
script: "./start.sh", // gradlew bootRun 실행
interpreter: "/bin/bash",
},
{
name: "tasteby-web",
cwd: "/Users/joungmin/workspaces/tasteby/frontend",
script: "npm",
args: "run dev", // ⚠️ 절대 standalone으로 바꾸지 말 것!
},
],
};
```
### 백엔드 start.sh
```bash
#!/bin/bash
export JAVA_HOME="/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home"
export PATH="/opt/homebrew/opt/openjdk@21/bin:$PATH"
set -a
source /Users/joungmin/workspaces/tasteby/backend/.env # 환경변수 로드
set +a
exec ./gradlew bootRun
```
### 코드 수정 후 반영 방법
```bash
# 프론트엔드: npm run dev라서 코드 수정 시 자동 Hot Reload (재시작 불필요)
# 백엔드: 코드 수정 후 재시작 필요
pm2 restart tasteby-api
# 전체 재시작
pm2 restart tasteby-api tasteby-web
# PM2 상태 확인
pm2 status
# 로그 확인
pm2 logs tasteby-api --lines 50
pm2 logs tasteby-web --lines 50
```
### 주의사항
- `tasteby-web`은 반드시 `npm run dev`로 실행 (dev server)
- standalone 모드(`node .next/standalone/server.js`)로 바꾸면 static/public 파일을 못 찾아서 404 발생
- standalone은 prod(Docker/K8s) 전용
- dev 포트: 프론트 3001, 백엔드 8000 (3000은 Gitea가 사용 중)
- 환경변수는 `backend/.env`에서 로드
---
## 2. Prod 환경 (www.tasteby.net)
### 구조
```
브라우저 → www.tasteby.net (HTTPS)
OCI Network Load Balancer (217.142.131.194)
↓ 80→NodePort:32530, 443→NodePort:31437
Nginx Ingress Controller (K8s)
├── /api/* → backend Service (:8000)
└── /* → frontend Service (:3001)
```
### 클러스터 정보
- **OKE 클러스터**: tasteby-cluster-prod
- **노드**: ARM64 × 2 (2 CPU / 8GB)
- **네임스페이스**: tasteby
- **K8s context**: `context-c6ap7ecrdeq`
### Pod 구성
| Pod | Image | Port | 리소스 |
|-----|-------|------|--------|
| backend | `icn.ocir.io/idyhsdamac8c/tasteby/backend:TAG` | 8000 | 500m~1 CPU, 768Mi~1536Mi |
| frontend | `icn.ocir.io/idyhsdamac8c/tasteby/frontend:TAG` | 3001 | 200m~500m CPU, 256Mi~512Mi |
| redis | `docker.io/library/redis:7-alpine` | 6379 | 100m~200m CPU, 128Mi~256Mi |
### 배포 명령어 (deploy.sh)
```bash
# 전체 배포 (백엔드 + 프론트엔드)
./deploy.sh "배포 메시지"
# 백엔드만 배포
./deploy.sh --backend-only "백엔드 수정 사항"
# 프론트엔드만 배포
./deploy.sh --frontend-only "프론트 수정 사항"
# 드라이런 (실제 배포 없이 확인)
./deploy.sh --dry-run "테스트"
```
### deploy.sh 동작 순서
1. **버전 계산**: 최신 git tag에서 patch +1 (v0.1.9 → v0.1.10)
2. **Docker 빌드**: Colima로 `linux/arm64` 이미지 빌드 (로컬 Mac에서)
- 백엔드: `backend-java/Dockerfile` → multi-stage (JDK build → JRE runtime)
- 프론트: `frontend/Dockerfile` → multi-stage (node build → standalone runtime)
3. **OCIR Push**: `icn.ocir.io/idyhsdamac8c/tasteby/{backend,frontend}:TAG` + `:latest`
4. **K8s 배포**: `kubectl set image``kubectl rollout status` (롤링 업데이트)
5. **Git tag**: `vX.Y.Z` 태그 생성 후 origin push
### Docker 빌드 상세
**백엔드 Dockerfile** (multi-stage):
```dockerfile
# Build: eclipse-temurin:21-jdk에서 gradlew bootJar
# Runtime: eclipse-temurin:21-jre에서 java -jar app.jar
# JVM 옵션: -XX:MaxRAMPercentage=75.0 -XX:+UseG1GC
```
**프론트엔드 Dockerfile** (multi-stage):
```dockerfile
# Build: node:22-alpine에서 npm ci + npm run build
# Runtime: node:22-alpine에서 standalone 출력물 복사 + node server.js
# ⚠️ standalone 모드는 Docker(prod) 전용. .next/static과 public을 직접 복사해야 함
```
### Ingress 설정
```yaml
# 주요 annotation
cert-manager.io/cluster-issuer: letsencrypt-prod # 자동 TLS 인증서
nginx.ingress.kubernetes.io/ssl-redirect: "true" # HTTP → HTTPS 리다이렉트
nginx.ingress.kubernetes.io/from-to-www-redirect: "true" # tasteby.net → www 리다이렉트
# 라우팅
www.tasteby.net/api/* → backend:8000
www.tasteby.net/* → frontend:3001
```
### TLS 인증서 (cert-manager)
- ClusterIssuer: `letsencrypt-prod`
- HTTP-01 challenge 방식 (포트 80 필수)
- Secret: `tasteby-tls`
- 인증서 상태 확인: `kubectl get certificate -n tasteby`
### 운영 확인 명령어
```bash
# Pod 상태
kubectl get pods -n tasteby
# 로그 확인
kubectl logs -f deployment/backend -n tasteby
kubectl logs -f deployment/frontend -n tasteby
# 인증서 상태
kubectl get certificate -n tasteby
# Ingress 상태
kubectl get ingress -n tasteby
# 롤백 (이전 이미지로)
kubectl rollout undo deployment/backend -n tasteby
kubectl rollout undo deployment/frontend -n tasteby
```
---
## 3. OCI 네트워크 구성
### VCN 서브넷
| 서브넷 | CIDR | 용도 |
|--------|------|------|
| oke-k8sApiEndpoint-subnet | 10.0.0.0/28 | K8s API 서버 |
| oke-nodesubnet | 10.0.10.0/24 | 워커 노드 |
| oke-svclbsubnet | 10.0.20.0/24 | NLB (로드밸런서) |
### 보안 리스트 (Security List)
**LB 서브넷** (oke-svclbsubnet):
- Ingress: `0.0.0.0/0` → TCP 80, 443
- Egress: `10.0.10.0/24` → all (노드 서브넷 전체 허용)
**노드 서브넷** (oke-nodesubnet):
- Ingress: `10.0.10.0/24` → all (노드 간 통신)
- Ingress: `10.0.0.0/28` → TCP all (API 서버)
- Ingress: `0.0.0.0/0` → TCP 22 (SSH)
- Ingress: `10.0.20.0/24` → TCP 30000-32767 (LB → NodePort)
- Ingress: `0.0.0.0/0` → TCP 30000-32767 (NLB preserve-source 대응)
> ⚠️ NLB `is-preserve-source: true` 설정으로 클라이언트 원본 IP가 보존됨.
> 따라서 노드 서브넷에 `0.0.0.0/0` → NodePort 인바운드가 반드시 필요.
---
## 4. OCIR (컨테이너 레지스트리) 인증
```bash
# 로그인
docker login icn.ocir.io -u idyhsdamac8c/oracleidentitycloudservice/<email> -p <auth-token>
```
- Registry: `icn.ocir.io/idyhsdamac8c/tasteby/`
- K8s imagePullSecret: `ocir-secret` (namespace: tasteby)
---
## 5. 자주 하는 실수 / 주의사항
| 실수 | 원인 | 해결 |
|------|------|------|
| dev에서 static 404 | PM2를 standalone 모드로 바꿈 | `npm run dev`로 원복 |
| prod HTTPS 타임아웃 | NLB 보안 리스트 NodePort 불일치 | egress를 노드 서브넷 all 허용 |
| 인증서 발급 실패 | 포트 80 방화벽 차단 | LB 서브넷 ingress 80 + 노드 서브넷 NodePort 허용 |
| OKE에서 이미지 pull 실패 | CRI-O short name 불가 | `docker.io/library/` 풀네임 사용 |
| NLB 헬스체크 실패 | preserve-source + 노드 보안 리스트 | 0.0.0.0/0 → NodePort 인바운드 추가 |

239
docs/troubleshooting.md Normal file
View File

@@ -0,0 +1,239 @@
# Tasteby 배포 트러블슈팅 기록
## 1. OCI DevOps Build Pipeline - 코드 접근 권한 오류
**오류:**
```
Unable to fetch build_spec file build_spec.yaml due to RelatedResourceNotAuthorizedOrNotFound.
Please check if dynamic groups and the corresponding policies are properly configured.
```
**원인:** OCI DevOps Build Pipeline이 Code Repository에 접근할 IAM 권한이 없음
**해결:**
1. Dynamic Group 생성 — 빌드 파이프라인 리소스를 포함하는 매칭 룰:
```
ANY {
resource.type = 'devopsbuildpipeline',
resource.type = 'devopsrepository',
resource.type = 'devopsdeploypipeline',
resource.type = 'devopsconnection'
}
```
2. IAM Policy 생성:
```
Allow dynamic-group tasteby-build-pipeline to manage devops-family in tenancy
Allow dynamic-group tasteby-build-pipeline to manage repos in tenancy
Allow dynamic-group tasteby-build-pipeline to read secret-family in tenancy
Allow dynamic-group tasteby-build-pipeline to manage generic-artifacts in tenancy
Allow dynamic-group tasteby-build-pipeline to use ons-topics in tenancy
```
3. IAM 정책 전파에 수 분 소요 — 적용 후 바로 빌드하면 동일 오류 발생할 수 있음
---
## 2. OCI DevOps Build Pipeline - Logs 미설정
**오류:**
```
Logs need to be enabled in order to run the builds.
Please enable logs for your project.
```
**원인:** DevOps 프로젝트에 OCI Logging이 설정되지 않음
**해결:**
1. OCI Logging > Log Group 생성 (예: `tasteby-devops-logs`)
2. Log Group에 Service Log 생성:
- Source: `devops` 서비스
- Resource: DevOps 프로젝트 OCID
- Category: `all`
3. DevOps 프로젝트에 Notification Topic 설정 필요
---
## 3. OCI DevOps Build Pipeline - ARM64 이미지 빌드 불가
**오류:**
```
Step 'Step_CommandV1_2' failed with exit code: '1' (docker build --platform linux/arm64)
Step 'Step_CommandV1_1' failed with exit code: '125' (QEMU 설정 시도)
```
**원인:**
- OCI DevOps Managed Build는 x86_64 러너만 제공 (`OL7_X86_64_STANDARD_10`)
- ARM64 이미지를 직접 빌드할 수 없음
- `--privileged` 모드가 허용되지 않아 QEMU 크로스빌드도 불가
**해결:**
- Colima (macOS 경량 Docker) 설치로 로컬 ARM64 빌드:
```bash
brew install colima docker
colima start --arch aarch64 --cpu 2 --memory 4
```
- deploy.sh로 로컬 빌드 → OCIR push → K8s 배포
---
## 4. OCI Code Repository - HTTPS 인증 실패
**오류:**
```
fatal: Authentication failed for 'https://devops.scmservice.ap-seoul-1.oci.oraclecloud.com/...'
```
**원인:** OCI Code Repository HTTPS 인증의 username 형식이 특수함
**해결:**
- IDCS 연동 사용자의 경우 username 형식:
```
<tenancy-name>/oracleidentitycloudservice/<oci-username>
```
예시: `joungminkoaws/oracleidentitycloudservice/joungmin.ko.aws@gmail.com`
- Password: OCI Auth Token (User Settings > Auth Tokens에서 생성)
- `idyhsdamac8c` (namespace)가 아닌 `joungminkoaws` (tenancy name)을 사용해야 함
---
## 5. Docker 빌드 컨텍스트 과대 (276MB)
**증상:**
```
Sending build context to Docker daemon 276.4MB
```
**원인:** `.dockerignore` 파일이 없어 `build/`, `.gradle/`, `node_modules/` 등이 포함됨
**해결:**
- `backend-java/.dockerignore` 생성:
```
build/
.gradle/
.idea/
*.iml
```
- `frontend/.dockerignore` 생성:
```
node_modules/
.next/
.env.local
```
- 결과: 276MB → 336KB (backend), 602KB (frontend)
---
## 6. Redis ImageInspectError (OKE CRI-O)
**오류:**
```
Failed to inspect image "": rpc error: code = Unknown desc = short name mode is enforcing,
but image name redis:7-alpine returns ambiguous list
```
**원인:** OKE는 CRI-O 컨테이너 런타임을 사용하며, short name (예: `redis:7-alpine`)을 허용하지 않음
**해결:**
- 이미지명에 full registry prefix 추가:
```yaml
# 변경 전
image: redis:7-alpine
# 변경 후
image: docker.io/library/redis:7-alpine
```
---
## 7. OCIR ImagePullBackOff (K8s)
**오류:**
```
Failed to pull image "icn.ocir.io/.../backend:latest": unable to retrieve auth token:
invalid username/password: unknown: Unauthorized
```
**원인:** K8s `ocir-secret`의 username 형식이 잘못됨
**해결:**
- IDCS 사용자의 경우 OCIR pull secret 생성 시:
```bash
kubectl create secret docker-registry ocir-secret \
--docker-server=icn.ocir.io \
--docker-username='<namespace>/oracleidentitycloudservice/<username>' \
--docker-password='<auth-token>' \
-n tasteby
```
- Docker login 시에도 동일한 형식:
```bash
docker login icn.ocir.io \
-u "<namespace>/oracleidentitycloudservice/<username>"
```
---
## 8. kubectl 인증 실패
**오류:**
```
error: You must be logged in to the server (Unauthorized)
```
**원인:** kubeconfig 생성 시 OCI 프로필이 지정되지 않음
**해결:**
- `~/.kube/config`의 user args에 `--profile JOUNGMINKOAWS` 추가:
```yaml
users:
- name: user-xxx
user:
exec:
args:
- ce
- cluster
- generate-token
- --cluster-id
- ocid1.cluster.oc1...
- --region
- ap-seoul-1
- --profile # ← 추가
- JOUNGMINKOAWS # ← 추가
```
---
## 9. Let's Encrypt 인증서 발급 실패 - Timeout during connect
**오류:**
```
acme: authorization error for www.tasteby.net: 400 urn:ietf:params:acme:error:connection:
64.110.90.89: Timeout during connect (likely firewall problem)
```
**원인:** OKE VCN의 Security List에서 LB 서브넷으로의 80/443 포트가 열려있지 않음
**해결:**
1. **LB 서브넷 Security List**에 Ingress 규칙 추가:
- `0.0.0.0/0` → TCP 80 (HTTP)
- `0.0.0.0/0` → TCP 443 (HTTPS)
- Egress: `0.0.0.0/0` → All protocols
2. **노드 서브넷 Security List**에 LB→노드 Ingress 규칙 추가:
- `10.0.20.0/24` (LB 서브넷 CIDR) → TCP 30000-32767 (NodePort)
- `10.0.20.0/24` → TCP 10256 (Health check)
3. 인증서 재발급:
```bash
kubectl delete certificate tasteby-tls -n tasteby
kubectl apply -f k8s/ingress.yaml
```
---
## 10. tasteby.net (root domain) DNS 미전파
**증상:** www.tasteby.net은 되지만 tasteby.net challenge가 `pending` 상태
**원인:** Namecheap에서 @ (root) A 레코드가 설정되지 않았거나 전파가 안 됨
**해결:**
- Ingress TLS에서 www.tasteby.net만 먼저 설정하여 인증서 발급
- root domain DNS 전파 완료 후 TLS hosts에 tasteby.net 추가하고 인증서 재발급

3
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
.next/
.env.local

16
frontend/dev-restart.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
# Build + restart dev server (standalone mode)
set -euo pipefail
cd "$(dirname "$0")"
echo "▶ Building..."
npm run build
echo "▶ Copying static files to standalone..."
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public 2>/dev/null || true
echo "▶ Restarting PM2..."
pm2 restart tasteby-web
echo "✅ Done — http://localhost:3001"

View File

@@ -0,0 +1,85 @@
# Tasteby Design Concept 후보
> Oracle의 Redwood처럼, Tasteby만의 디자인 언어를 정의하기 위한 컨셉 후보안.
---
## 1. Saffron (사프란) 🟠
따뜻한 금빛 오렌지. 고급스러운 미식 큐레이션 느낌.
| 역할 | 색상 | Hex |
|------|------|-----|
| Primary | 깊은 오렌지 | `#E8720C` |
| Primary Light | 밝은 오렌지 | `#F59E3F` |
| Primary Dark | 진한 오렌지 | `#C45A00` |
| Accent | 골드 | `#F5A623` |
| Accent Light | 라이트 골드 | `#FFD080` |
| Background | 크림 화이트 | `#FFFAF5` |
| Surface | 웜 그레이 | `#F7F3EF` |
| Text Primary | 다크 브라운 | `#2C1810` |
| Text Secondary | 미디엄 브라운 | `#7A6555` |
**키워드**: 프리미엄, 미식, 큐레이션, 따뜻함, 신뢰
**어울리는 폰트**: Pretendard, Noto Sans KR (깔끔 + 웜톤 배경)
---
## 2. Gochujang (고추장) 🔴
한국 음식 DNA. 약간 붉은 오렌지 톤으로 대담하고 강렬.
| 역할 | 색상 | Hex |
|------|------|-----|
| Primary | 고추장 레드 | `#D94F30` |
| Primary Light | 밝은 레드 | `#EF7B5A` |
| Primary Dark | 진한 레드 | `#B53518` |
| Accent | 따뜻한 오렌지 | `#FF8C42` |
| Accent Light | 라이트 피치 | `#FFB88C` |
| Background | 소프트 화이트 | `#FFFBF8` |
| Surface | 웜 베이지 | `#F5F0EB` |
| Text Primary | 차콜 | `#1A1A1A` |
| Text Secondary | 다크 그레이 | `#666052` |
**키워드**: 한국, 활기, 식욕, 대담, 강렬
**어울리는 폰트**: Spoqa Han Sans Neo, Pretendard (모던 + 힘있는)
---
## 3. Citrus (시트러스) 🍊
밝고 상큼한 비비드 오렌지. 현대적이고 친근한 느낌.
| 역할 | 색상 | Hex |
|------|------|-----|
| Primary | 비비드 오렌지 | `#FF6B2B` |
| Primary Light | 라이트 오렌지 | `#FF9A6C` |
| Primary Dark | 딥 오렌지 | `#E04D10` |
| Accent | 피치 | `#FFB347` |
| Accent Light | 소프트 피치 | `#FFD9A0` |
| Background | 퓨어 화이트 | `#FFFFFF` |
| Surface | 쿨 그레이 | `#F5F5F7` |
| Text Primary | 뉴트럴 블랙 | `#171717` |
| Text Secondary | 미디엄 그레이 | `#6B7280` |
**키워드**: 캐주얼, 트렌디, 활발, 친근, 상큼
**어울리는 폰트**: Geist (현재 사용 중), Inter
---
## 현재 상태 (Before)
- Tailwind 기본 `orange` 팔레트 사용 (커스텀 없음)
- 폰트: Geist (Google Fonts)
- 다크모드: `prefers-color-scheme` 기반 자동 전환
- 브랜드 컬러 정의 없음 — 컴포넌트마다 `orange-400~700` 개별 적용
## 적용 계획
1. 컨셉 선택
2. CSS 변수로 디자인 토큰 정의 (`globals.css`)
3. Tailwind v4 `@theme` 에 커스텀 컬러 등록
4. 컴포넌트별 하드코딩된 orange → 시맨틱 토큰으로 교체
5. 다크모드 팔레트 정의
6. 폰트 교체 (필요시)
7. 로고/아이콘 톤 맞춤

View File

@@ -9,10 +9,13 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@react-oauth/google": "^0.13.4", "@react-oauth/google": "^0.13.4",
"@tabler/icons-react": "^3.40.0",
"@types/supercluster": "^7.1.3",
"@vis.gl/react-google-maps": "^1.7.1", "@vis.gl/react-google-maps": "^1.7.1",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"supercluster": "^8.0.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -1255,6 +1258,32 @@
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
}, },
"node_modules/@tabler/icons": {
"version": "3.40.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.40.0.tgz",
"integrity": "sha512-V/Q4VgNPKubRTiLdmWjV/zscYcj5IIk+euicUtaVVqF6luSC9rDngYWgST5/yh3Mrg/mYUwRv1YVTk71Jp0twQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/icons-react": {
"version": "3.40.0",
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.40.0.tgz",
"integrity": "sha512-oO5+6QCnna4a//mYubx4euZfECtzQZFDGsDMIdzZUhbdyBCT+3bRVFBPueGIcemWld4Vb/0UQ39C/cmGfGylAg==",
"license": "MIT",
"dependencies": {
"@tabler/icons": "3.40.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
},
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
@@ -1544,6 +1573,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/google.maps": { "node_modules/@types/google.maps": {
"version": "3.58.1", "version": "3.58.1",
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz", "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz",
@@ -1595,6 +1630,15 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.1", "version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
@@ -4555,6 +4599,12 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/kdbush": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
"license": "ISC"
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -6086,6 +6136,15 @@
} }
} }
}, },
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",

View File

@@ -10,10 +10,13 @@
}, },
"dependencies": { "dependencies": {
"@react-oauth/google": "^0.13.4", "@react-oauth/google": "^0.13.4",
"@tabler/icons-react": "^3.40.0",
"@types/supercluster": "^7.1.3",
"@vis.gl/react-google-maps": "^1.7.1", "@vis.gl/react-google-maps": "^1.7.1",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"supercluster": "^8.0.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,52 @@
@import "tailwindcss"; @import "tailwindcss";
/* Force light mode: dark: classes only activate with .dark ancestor */
@custom-variant dark (&:is(.dark *));
:root { :root {
--background: #ffffff; --background: #FFFAF5;
--foreground: #171717; --foreground: #171717;
color-scheme: light dark; --surface: #FFFFFF;
--brand-50: #FFF8F0;
--brand-100: #FFEDD5;
--brand-200: #FFD6A5;
--brand-300: #FFBC72;
--brand-400: #F5A623;
--brand-500: #F59E3F;
--brand-600: #E8720C;
--brand-700: #C45A00;
--brand-800: #9A4500;
--brand-900: #6B3000;
--brand-950: #3D1A00;
color-scheme: only light !important;
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist); --color-surface: var(--surface);
--color-brand-50: var(--brand-50);
--color-brand-100: var(--brand-100);
--color-brand-200: var(--brand-200);
--color-brand-300: var(--brand-300);
--color-brand-400: var(--brand-400);
--color-brand-500: var(--brand-500);
--color-brand-600: var(--brand-600);
--color-brand-700: var(--brand-700);
--color-brand-800: var(--brand-800);
--color-brand-900: var(--brand-900);
--color-brand-950: var(--brand-950);
--font-sans: var(--font-pretendard), var(--font-geist), system-ui, sans-serif;
} }
@media (prefers-color-scheme: dark) { /* Dark mode CSS vars (disabled — activate by adding .dark class to <html>) */
:root { /*
--background: #0a0a0a; .dark {
--background: #12100E;
--foreground: #ededed; --foreground: #ededed;
} --surface: #1C1916;
} }
*/
body { body {
background: var(--background); background: var(--background);
@@ -42,3 +71,42 @@ html, body, #__next {
.gm-style .gm-style-iw-d { .gm-style .gm-style-iw-d {
overflow: auto !important; overflow: auto !important;
} }
/* Hide scrollbar but keep scrolling */
@layer utilities {
.scrollbar-hide {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
overflow: -moz-scrollbars-none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}
}
/* Material Symbols */
.material-symbols-rounded {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
font-size: inherit;
line-height: 1;
vertical-align: middle;
}
.material-symbols-rounded.filled {
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
/* Safe area for iOS bottom nav */
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* Filter sheet slide-up animation */
@keyframes slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.animate-slide-up {
animation: slide-up 0.25s ease-out;
}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist } from "next/font/google"; import { Geist } from "next/font/google";
import localFont from "next/font/local";
import "./globals.css"; import "./globals.css";
import { Providers } from "./providers"; import { Providers } from "./providers";
@@ -8,6 +9,14 @@ const geist = Geist({
subsets: ["latin"], subsets: ["latin"],
}); });
const pretendard = localFont({
src: [
{ path: "../fonts/PretendardVariable.woff2", style: "normal" },
],
variable: "--font-pretendard",
display: "swap",
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Tasteby - YouTube Restaurant Map", title: "Tasteby - YouTube Restaurant Map",
description: "YouTube food channel restaurant map service", description: "YouTube food channel restaurant map service",
@@ -19,8 +28,15 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="ko" className="dark:bg-gray-950" suppressHydrationWarning> <html lang="ko" className="bg-background" style={{ colorScheme: "only light" }} suppressHydrationWarning>
<body className={`${geist.variable} font-sans antialiased`}> <head>
<meta name="color-scheme" content="only light" />
<meta name="supported-color-schemes" content="light only" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet" />
</head>
<body className={`${pretendard.variable} ${geist.variable} font-sans antialiased`}>
<Providers>{children}</Providers> <Providers>{children}</Providers>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@@ -89,7 +89,7 @@ export default function BottomSheet({ open, onClose, children }: BottomSheetProp
{/* Sheet */} {/* Sheet */}
<div <div
ref={sheetRef} ref={sheetRef}
className="fixed bottom-0 left-0 right-0 z-50 md:hidden flex flex-col bg-white/85 dark:bg-gray-900/90 backdrop-blur-xl rounded-t-2xl shadow-2xl" className="fixed bottom-0 left-0 right-0 z-50 md:hidden flex flex-col bg-surface/85 backdrop-blur-xl rounded-t-2xl shadow-2xl"
style={{ style={{
height: `${height * 100}vh`, height: `${height * 100}vh`,
transition: dragging ? "none" : "height 0.3s cubic-bezier(0.2, 0, 0, 1)", transition: dragging ? "none" : "height 0.3s cubic-bezier(0.2, 0, 0, 1)",

View File

@@ -0,0 +1,112 @@
"use client";
import { useEffect, useRef } from "react";
import Icon from "@/components/Icon";
export interface FilterOption {
label: string;
value: string;
group?: string;
}
interface FilterSheetProps {
open: boolean;
onClose: () => void;
title: string;
options: FilterOption[];
value: string;
onChange: (value: string) => void;
}
export default function FilterSheet({ open, onClose, title, options, value, onChange }: FilterSheetProps) {
const sheetRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, [open]);
// Group options by group field
const grouped = options.reduce<Record<string, FilterOption[]>>((acc, opt) => {
const key = opt.group || "";
if (!acc[key]) acc[key] = [];
acc[key].push(opt);
return acc;
}, {});
const groups = Object.keys(grouped);
const handleSelect = (v: string) => {
onChange(v);
onClose();
};
if (!open) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-[60] bg-black/30 md:hidden"
onClick={onClose}
/>
{/* Sheet */}
<div
ref={sheetRef}
className="fixed bottom-0 left-0 right-0 z-[61] md:hidden bg-surface rounded-t-2xl shadow-2xl max-h-[70vh] flex flex-col animate-slide-up"
>
{/* Handle */}
<div className="flex justify-center pt-2 pb-1 shrink-0">
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-gray-800 shrink-0">
<h3 className="font-bold text-base text-gray-900 dark:text-gray-100">{title}</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600">
<Icon name="close" size={20} />
</button>
</div>
{/* Options */}
<div className="flex-1 overflow-y-auto overscroll-contain pb-safe">
{/* 전체(초기화) */}
<button
onClick={() => handleSelect("")}
className={`w-full text-left px-4 py-3 flex items-center justify-between border-b border-gray-50 dark:border-gray-800/50 ${
!value ? "text-brand-600 dark:text-brand-400 font-medium bg-brand-50/50 dark:bg-brand-900/20" : "text-gray-700 dark:text-gray-300"
}`}
>
<span className="text-[15px]"></span>
{!value && <Icon name="check" size={18} className="text-brand-500" />}
</button>
{groups.map((group) => (
<div key={group}>
{group && (
<div className="px-4 py-2.5 text-xs font-semibold text-gray-400 dark:text-gray-500 tracking-wider bg-gray-50 dark:bg-gray-800/50 sticky top-0">
{group}
</div>
)}
{grouped[group].map((opt) => (
<button
key={opt.value}
onClick={() => handleSelect(opt.value)}
className={`w-full text-left px-4 py-3 flex items-center justify-between border-b border-gray-50 dark:border-gray-800/50 active:bg-gray-100 dark:active:bg-gray-800 ${
value === opt.value
? "text-brand-600 dark:text-brand-400 font-medium bg-brand-50/50 dark:bg-brand-900/20"
: "text-gray-700 dark:text-gray-300"
}`}
>
<span className="text-[15px]">{opt.label}</span>
{value === opt.value && <Icon name="check" size={18} className="text-brand-500" />}
</button>
))}
</div>
))}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,23 @@
"use client";
interface IconProps {
name: string;
size?: number;
filled?: boolean;
className?: string;
}
/**
* Material Symbols Rounded icon wrapper.
* Usage: <Icon name="search" size={20} />
*/
export default function Icon({ name, size = 20, filled, className = "" }: IconProps) {
return (
<span
className={`material-symbols-rounded ${filled ? "filled" : ""} ${className}`}
style={{ fontSize: size }}
>
{name}
</span>
);
}

View File

@@ -0,0 +1,60 @@
"use client";
import { useState } from "react";
import { createPortal } from "react-dom";
import { GoogleLogin } from "@react-oauth/google";
interface LoginMenuProps {
onGoogleSuccess: (credential: string) => void;
}
export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) {
const [open, setOpen] = useState(false);
return (
<>
<button
onClick={() => setOpen(true)}
className="px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400 border border-gray-300 dark:border-gray-600 hover:border-brand-400 dark:hover:border-brand-500 rounded-lg transition-colors"
>
</button>
{open && createPortal(
<div
className="fixed inset-0 flex items-center justify-center bg-black/40 backdrop-blur-sm"
style={{ zIndex: 99999 }}
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 className="flex items-center justify-between">
<h3 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"
>
</button>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500"> </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")}
size="large"
width="260"
text="signin_with"
/>
</div>
</div>
</div>,
document.body,
)}
</>
);
}

View File

@@ -8,8 +8,10 @@ import {
InfoWindow, InfoWindow,
useMap, useMap,
} from "@vis.gl/react-google-maps"; } from "@vis.gl/react-google-maps";
import Supercluster from "supercluster";
import type { Restaurant } from "@/lib/api"; import type { Restaurant } from "@/lib/api";
import { getCuisineIcon } from "@/lib/cuisine-icons"; import { getCuisineIcon } from "@/lib/cuisine-icons";
import Icon from "@/components/Icon";
const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 }; const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 };
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ""; const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
@@ -57,33 +59,87 @@ interface MapViewProps {
onSelectRestaurant?: (r: Restaurant) => void; onSelectRestaurant?: (r: Restaurant) => void;
onBoundsChanged?: (bounds: MapBounds) => void; onBoundsChanged?: (bounds: MapBounds) => void;
flyTo?: FlyTo | null; flyTo?: FlyTo | null;
onMyLocation?: () => void;
activeChannel?: string;
} }
function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo }: MapViewProps) { type RestaurantProps = { restaurant: Restaurant };
type RestaurantFeature = Supercluster.PointFeature<RestaurantProps>;
function useSupercluster(restaurants: Restaurant[]) {
const indexRef = useRef<Supercluster<{ restaurant: Restaurant }> | null>(null);
const points: RestaurantFeature[] = useMemo(
() =>
restaurants.map((r) => ({
type: "Feature" as const,
geometry: { type: "Point" as const, coordinates: [r.longitude, r.latitude] },
properties: { restaurant: r },
})),
[restaurants]
);
const index = useMemo(() => {
const sc = new Supercluster<{ restaurant: Restaurant }>({
radius: 60,
maxZoom: 16,
minPoints: 2,
});
sc.load(points);
indexRef.current = sc;
return sc;
}, [points]);
const getClusters = useCallback(
(bounds: MapBounds, zoom: number) => {
return index.getClusters(
[bounds.west, bounds.south, bounds.east, bounds.north],
Math.floor(zoom)
);
},
[index]
);
const getExpansionZoom = useCallback(
(clusterId: number): number => {
try {
return index.getClusterExpansionZoom(clusterId);
} catch {
return 17;
}
},
[index]
);
return { getClusters, getExpansionZoom, index };
}
function getClusterSize(count: number): number {
if (count < 10) return 36;
if (count < 50) return 42;
if (count < 100) return 48;
return 54;
}
function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeChannel }: Omit<MapViewProps, "onMyLocation" | "onBoundsChanged">) {
const map = useMap(); const map = useMap();
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null); const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
const [zoom, setZoom] = useState(13);
const [bounds, setBounds] = useState<MapBounds | null>(null);
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]); const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
const boundsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
// Report bounds on idle (debounced) // Build a lookup for restaurants by id
useEffect(() => { const restaurantMap = useMemo(() => {
if (!map) return; const m: Record<string, Restaurant> = {};
const listener = map.addListener("idle", () => { restaurants.forEach((r) => { m[r.id] = r; });
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current); return m;
boundsTimerRef.current = setTimeout(() => { }, [restaurants]);
const b = map.getBounds();
if (b && onBoundsChanged) { const clusters = useMemo(() => {
const ne = b.getNorthEast(); if (!bounds) return [];
const sw = b.getSouthWest(); return getClusters(bounds, zoom);
onBoundsChanged({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() }); }, [bounds, zoom, getClusters]);
}
}, 300);
});
return () => {
google.maps.event.removeListener(listener);
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
};
}, [map, onBoundsChanged]);
const handleMarkerClick = useCallback( const handleMarkerClick = useCallback(
(r: Restaurant) => { (r: Restaurant) => {
@@ -93,6 +149,41 @@ function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged
[onSelectRestaurant] [onSelectRestaurant]
); );
const handleClusterClick = useCallback(
(clusterId: number, lng: number, lat: number) => {
if (!map) return;
const expansionZoom = Math.min(getExpansionZoom(clusterId), 18);
map.panTo({ lat, lng });
map.setZoom(expansionZoom);
},
[map, getExpansionZoom]
);
// Track camera changes for clustering
useEffect(() => {
if (!map) return;
const listener = map.addListener("idle", () => {
const b = map.getBounds();
const z = map.getZoom();
if (b && z != null) {
const ne = b.getNorthEast();
const sw = b.getSouthWest();
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
setZoom(z);
}
});
// Trigger initial bounds
const b = map.getBounds();
const z = map.getZoom();
if (b && z != null) {
const ne = b.getNorthEast();
const sw = b.getSouthWest();
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
setZoom(z);
}
return () => google.maps.event.removeListener(listener);
}, [map]);
// Fly to a specific location (region filter) // Fly to a specific location (region filter)
useEffect(() => { useEffect(() => {
if (!map || !flyTo) return; if (!map || !flyTo) return;
@@ -110,10 +201,50 @@ function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged
return ( return (
<> <>
{restaurants.map((r) => { {clusters.map((feature) => {
const [lng, lat] = feature.geometry.coordinates;
const isCluster = feature.properties && "cluster" in feature.properties && feature.properties.cluster;
if (isCluster) {
const { cluster_id, point_count } = feature.properties as Supercluster.ClusterProperties;
const size = getClusterSize(point_count);
return (
<AdvancedMarker
key={`cluster-${cluster_id}`}
position={{ lat, lng }}
onClick={() => handleClusterClick(cluster_id, lng, lat)}
zIndex={100}
>
<div
style={{
width: size,
height: size,
borderRadius: "50%",
background: "linear-gradient(135deg, #E8720C 0%, #f59e0b 100%)",
border: "3px solid #fff",
boxShadow: "0 2px 8px rgba(0,0,0,0.25)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: size > 42 ? 15 : 13,
fontWeight: 700,
cursor: "pointer",
transition: "transform 0.2s ease",
}}
>
{point_count}
</div>
</AdvancedMarker>
);
}
// Individual marker
const r = (feature.properties as { restaurant: Restaurant }).restaurant;
const isSelected = selected?.id === r.id; const isSelected = selected?.id === r.id;
const isClosed = r.business_status === "CLOSED_PERMANENTLY"; const isClosed = r.business_status === "CLOSED_PERMANENTLY";
const chColor = r.channels?.[0] ? channelColors[r.channels[0]] : CHANNEL_COLORS[0]; const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
const chColor = chKey ? channelColors[chKey] : CHANNEL_COLORS[0];
const c = chColor || CHANNEL_COLORS[0]; const c = chColor || CHANNEL_COLORS[0];
return ( return (
<AdvancedMarker <AdvancedMarker
@@ -142,7 +273,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged
textDecoration: isClosed ? "line-through" : "none", textDecoration: isClosed ? "line-through" : "none",
}} }}
> >
<span style={{ marginRight: 3 }}>{getCuisineIcon(r.cuisine_type)}</span> <span className="material-symbols-rounded" style={{ fontSize: 14, marginRight: 3, verticalAlign: "middle", color: "#E8720C" }}>{getCuisineIcon(r.cuisine_type)}</span>
{r.name} {r.name}
</div> </div>
<div <div
@@ -167,7 +298,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged
> >
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1"> <div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="font-bold text-base" style={{ color: "#171717" }}>{getCuisineIcon(infoTarget.cuisine_type)} {infoTarget.name}</h3> <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>
{infoTarget.business_status === "CLOSED_PERMANENTLY" && ( {infoTarget.business_status === "CLOSED_PERMANENTLY" && (
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold"></span> <span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold"></span>
)} )}
@@ -184,16 +315,16 @@ function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged
</p> </p>
)} )}
{infoTarget.cuisine_type && ( {infoTarget.cuisine_type && (
<p className="text-sm text-gray-600">{infoTarget.cuisine_type}</p> <p className="text-xs text-gray-500">{infoTarget.cuisine_type}</p>
)} )}
{infoTarget.address && ( {infoTarget.address && (
<p className="text-xs text-gray-500 mt-1">{infoTarget.address}</p> <p className="text-[11px] text-gray-400 mt-1">{infoTarget.address}</p>
)} )}
{infoTarget.price_range && ( {infoTarget.price_range && (
<p className="text-xs text-gray-500">{infoTarget.price_range}</p> <p className="text-[11px] text-gray-400">{infoTarget.price_range}</p>
)} )}
{infoTarget.phone && ( {infoTarget.phone && (
<p className="text-xs text-gray-500">{infoTarget.phone}</p> <p className="text-[11px] text-gray-400">{infoTarget.phone}</p>
)} )}
<button <button
onClick={() => onSelectRestaurant?.(infoTarget)} onClick={() => onSelectRestaurant?.(infoTarget)}
@@ -208,30 +339,55 @@ function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged
); );
} }
export default function MapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo }: MapViewProps) { export default function MapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo, onMyLocation, activeChannel }: MapViewProps) {
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]); const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
const channelNames = useMemo(() => Object.keys(channelColors), [channelColors]); const channelNames = useMemo(() => {
const names = Object.keys(channelColors);
if (activeChannel) return names.filter((n) => n === activeChannel);
return names;
}, [channelColors, activeChannel]);
const boundsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCameraChanged = useCallback((ev: { detail: { bounds: { north: number; south: number; east: number; west: number } } }) => {
if (!onBoundsChanged) return;
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
boundsTimerRef.current = setTimeout(() => {
const { north, south, east, west } = ev.detail.bounds;
onBoundsChanged({ north, south, east, west });
}, 150);
}, [onBoundsChanged]);
return ( return (
<APIProvider apiKey={API_KEY}> <APIProvider apiKey={API_KEY}>
<Map <Map
defaultCenter={SEOUL_CENTER} defaultCenter={SEOUL_CENTER}
defaultZoom={12} defaultZoom={13}
mapId="tasteby-map" mapId="tasteby-map"
className="h-full w-full" className="h-full w-full"
colorScheme="LIGHT" colorScheme="LIGHT"
mapTypeControl={false} mapTypeControl={false}
fullscreenControl={false}
onCameraChanged={handleCameraChanged}
> >
<MapContent <MapContent
restaurants={restaurants} restaurants={restaurants}
selected={selected} selected={selected}
onSelectRestaurant={onSelectRestaurant} onSelectRestaurant={onSelectRestaurant}
onBoundsChanged={onBoundsChanged}
flyTo={flyTo} flyTo={flyTo}
activeChannel={activeChannel}
/> />
</Map> </Map>
{channelNames.length > 1 && ( {onMyLocation && (
<div className="absolute bottom-2 left-2 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm rounded-lg shadow px-2.5 py-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] z-10"> <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"
title="내 위치"
>
<Icon name="my_location" size={20} />
</button>
)}
{channelNames.length > 0 && (
<div className="absolute bottom-2 left-2 bg-surface/90 backdrop-blur-sm rounded-lg shadow px-2.5 py-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] z-10">
{channelNames.map((ch) => ( {channelNames.map((ch) => (
<div key={ch} className="flex items-center gap-1"> <div key={ch} className="flex items-center gap-1">
<span <span

View File

@@ -0,0 +1,194 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { Memo } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import Icon from "@/components/Icon";
interface MemoSectionProps {
restaurantId: string;
}
function StarSelector({
value,
onChange,
}: {
value: number;
onChange: (v: number) => void;
}) {
return (
<div className="flex items-center gap-1">
<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>
))}
</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);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState(false);
// Form state
const [rating, setRating] = useState(3);
const [text, setText] = useState("");
const [visitedAt, setVisitedAt] = useState(new Date().toISOString().slice(0, 10));
const [submitting, setSubmitting] = useState(false);
const loadMemo = useCallback(() => {
if (!user) { setLoading(false); return; }
setLoading(true);
api.getMemo(restaurantId)
.then(setMemo)
.catch(() => setMemo(null))
.finally(() => setLoading(false));
}, [restaurantId, user]);
useEffect(() => {
loadMemo();
}, [loadMemo]);
if (!user) return null;
const startEdit = () => {
if (memo) {
setRating(memo.rating || 3);
setText(memo.memo_text || "");
setVisitedAt(memo.visited_at || new Date().toISOString().slice(0, 10));
} else {
setRating(3);
setText("");
setVisitedAt(new Date().toISOString().slice(0, 10));
}
setEditing(true);
setShowForm(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
const saved = await api.upsertMemo(restaurantId, {
rating,
memo_text: text || undefined,
visited_at: visitedAt || undefined,
});
setMemo(saved);
setShowForm(false);
setEditing(false);
} finally {
setSubmitting(false);
}
};
const handleDelete = async () => {
if (!confirm("메모를 삭제하시겠습니까?")) return;
await api.deleteMemo(restaurantId);
setMemo(null);
};
return (
<div className="mt-4">
<div className="flex items-center gap-2 mb-2">
<Icon name="edit_note" size={18} className="text-brand-600" />
<h3 className="font-semibold text-sm"> </h3>
<span className="text-[10px] text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded"></span>
</div>
{loading ? (
<div className="animate-pulse space-y-2">
<div className="h-3 w-32 bg-gray-200 rounded" />
<div className="h-3 w-full bg-gray-200 rounded" />
</div>
) : showForm ? (
<form onSubmit={handleSubmit} className="space-y-3 border border-brand-200 rounded-lg p-3 bg-brand-50/30">
<StarSelector value={rating} onChange={setRating} />
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="나만의 메모를 작성하세요 (선택)"
className="w-full border rounded p-2 text-sm resize-none"
rows={3}
/>
<div className="flex items-center gap-2">
<label className="text-xs text-gray-500">:</label>
<input
type="date"
value={visitedAt}
onChange={(e) => setVisitedAt(e.target.value)}
className="border rounded px-2 py-1 text-xs"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={submitting}
className="px-3 py-1 bg-brand-500 text-white text-sm rounded hover:bg-brand-600 disabled:opacity-50"
>
{submitting ? "저장 중..." : editing && memo ? "수정" : "저장"}
</button>
<button
type="button"
onClick={() => { setShowForm(false); setEditing(false); }}
className="px-3 py-1 bg-gray-200 text-gray-700 text-sm rounded hover:bg-gray-300"
>
</button>
</div>
</form>
) : 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.visited_at && (
<span className="text-xs text-gray-400">: {memo.visited_at}</span>
)}
</div>
{memo.memo_text && (
<p className="text-sm text-gray-700 mt-1">{memo.memo_text}</p>
)}
<div className="flex gap-2 mt-2">
<button onClick={startEdit} className="text-xs text-blue-600 hover:underline"></button>
<button onClick={handleDelete} className="text-xs text-red-600 hover:underline"></button>
</div>
</div>
) : (
<button
onClick={startEdit}
className="px-3 py-1.5 border border-dashed border-brand-300 text-brand-600 text-sm rounded-lg hover:bg-brand-50 transition-colors"
>
<Icon name="add" size={14} className="mr-0.5" />
</button>
)}
</div>
);
}

View File

@@ -1,36 +1,72 @@
"use client"; "use client";
import type { Review } from "@/lib/api"; import { useState } from "react";
import type { Review, Memo } from "@/lib/api";
import Icon from "@/components/Icon";
interface MyReview extends Review { interface MyReview extends Review {
restaurant_id: string; restaurant_id: string;
restaurant_name: string | null; restaurant_name: string | null;
} }
interface MyMemo extends Memo {
restaurant_name: string | null;
}
interface MyReviewsListProps { interface MyReviewsListProps {
reviews: MyReview[]; reviews: MyReview[];
memos: MyMemo[];
onClose: () => void; onClose: () => void;
onSelectRestaurant: (restaurantId: string) => void; onSelectRestaurant: (restaurantId: string) => void;
} }
export default function MyReviewsList({ export default function MyReviewsList({
reviews, reviews,
memos,
onClose, onClose,
onSelectRestaurant, onSelectRestaurant,
}: MyReviewsListProps) { }: MyReviewsListProps) {
const [tab, setTab] = useState<"reviews" | "memos">("reviews");
return ( return (
<div className="p-4 space-y-3"> <div className="p-4 space-y-3">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h2 className="font-bold text-lg"> ({reviews.length})</h2> <h2 className="font-bold text-lg"> </h2>
<button <button
onClick={onClose} onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-xl leading-none" className="text-gray-400 hover:text-gray-600"
> >
x <Icon name="close" size={18} />
</button> </button>
</div> </div>
{reviews.length === 0 ? ( <div className="flex gap-1 border-b">
<button
onClick={() => setTab("reviews")}
className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${
tab === "reviews"
? "border-brand-500 text-brand-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
<Icon name="rate_review" size={14} className="mr-1" />
({reviews.length})
</button>
<button
onClick={() => setTab("memos")}
className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${
tab === "memos"
? "border-brand-500 text-brand-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
<Icon name="edit_note" size={14} className="mr-1" />
({memos.length})
</button>
</div>
{tab === "reviews" ? (
reviews.length === 0 ? (
<p className="text-sm text-gray-500 py-8 text-center"> <p className="text-sm text-gray-500 py-8 text-center">
. .
</p> </p>
@@ -63,6 +99,44 @@ export default function MyReviewsList({
</button> </button>
))} ))}
</div> </div>
)
) : (
memos.length === 0 ? (
<p className="text-sm text-gray-500 py-8 text-center">
.
</p>
) : (
<div className="space-y-2">
{memos.map((m) => (
<button
key={m.id}
onClick={() => onSelectRestaurant(m.restaurant_id)}
className="w-full text-left border border-brand-200 rounded-lg p-3 bg-brand-50/30 hover:bg-brand-50 transition-colors"
>
<div className="flex items-center justify-between mb-1">
<span className="font-semibold text-sm truncate">
{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>
)}
</div>
{m.memo_text && (
<p className="text-xs text-gray-600 line-clamp-2">
{m.memo_text}
</p>
)}
<div className="flex items-center gap-2 mt-1.5 text-[10px] text-gray-400">
{m.visited_at && <span>: {m.visited_at}</span>}
<span className="text-brand-400"></span>
</div>
</button>
))}
</div>
)
)} )}
</div> </div>
); );

View File

@@ -4,7 +4,9 @@ import { useEffect, useState } from "react";
import { api, getToken } from "@/lib/api"; import { api, getToken } from "@/lib/api";
import type { Restaurant, VideoLink } from "@/lib/api"; import type { Restaurant, VideoLink } from "@/lib/api";
import ReviewSection from "@/components/ReviewSection"; import ReviewSection from "@/components/ReviewSection";
import MemoSection from "@/components/MemoSection";
import { RestaurantDetailSkeleton } from "@/components/Skeleton"; import { RestaurantDetailSkeleton } from "@/components/Skeleton";
import Icon from "@/components/Icon";
interface RestaurantDetailProps { interface RestaurantDetailProps {
restaurant: Restaurant; restaurant: Restaurant;
@@ -50,7 +52,7 @@ export default function RestaurantDetail({
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-lg font-bold dark:text-gray-100">{restaurant.name}</h2> <h2 className="text-xl font-bold dark:text-gray-100">{restaurant.name}</h2>
{getToken() && ( {getToken() && (
<button <button
onClick={handleToggleFavorite} onClick={handleToggleFavorite}
@@ -60,7 +62,7 @@ export default function RestaurantDetail({
}`} }`}
title={favorited ? "찜 해제" : "찜하기"} title={favorited ? "찜 해제" : "찜하기"}
> >
{favorited ? "♥" : "♡"} <Icon name="favorite" size={20} filled={favorited} />
</button> </button>
)} )}
{restaurant.business_status === "CLOSED_PERMANENTLY" && ( {restaurant.business_status === "CLOSED_PERMANENTLY" && (
@@ -78,12 +80,13 @@ export default function RestaurantDetail({
onClick={onClose} onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none" className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none"
> >
x <Icon name="close" size={18} />
</button> </button>
</div> </div>
{restaurant.rating && ( {restaurant.rating && (
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<span className="text-blue-500 dark:text-blue-400 font-medium text-xs">Google</span>
<span className="text-yellow-500 dark:text-yellow-400">{"★".repeat(Math.round(restaurant.rating))}</span> <span className="text-yellow-500 dark:text-yellow-400">{"★".repeat(Math.round(restaurant.rating))}</span>
<span className="font-medium dark:text-gray-200">{restaurant.rating}</span> <span className="font-medium dark:text-gray-200">{restaurant.rating}</span>
{restaurant.rating_count && ( {restaurant.rating_count && (
@@ -92,49 +95,83 @@ export default function RestaurantDetail({
</div> </div>
)} )}
<div className="space-y-1 text-sm dark:text-gray-300"> <div className="space-y-1 text-xs text-gray-500 dark:text-gray-400">
{restaurant.cuisine_type && ( {restaurant.cuisine_type && (
<p> <p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.cuisine_type} <span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{restaurant.cuisine_type}</span>
</p> </p>
)} )}
{restaurant.address && ( {restaurant.address && (
<p> <p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.address} <span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{restaurant.address}</span>
</p> </p>
)} )}
{restaurant.region && ( {restaurant.region && (
<p> <p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.region} <span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{restaurant.region}</span>
</p> </p>
)} )}
{restaurant.price_range && ( {restaurant.price_range && (
<p> <p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.price_range} <span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{restaurant.price_range}</span>
</p> </p>
)} )}
{restaurant.phone && ( {restaurant.phone && (
<p> <p>
<span className="text-gray-500 dark:text-gray-400">:</span>{" "} <span className="text-gray-400 dark:text-gray-500"></span>{" "}
<a href={`tel:${restaurant.phone}`} className="text-orange-600 dark:text-orange-400 hover:underline"> <a href={`tel:${restaurant.phone}`} className="text-brand-600 dark:text-brand-400 hover:underline">
{restaurant.phone} {restaurant.phone}
</a> </a>
</p> </p>
)} )}
{restaurant.google_place_id && ( {restaurant.google_place_id && (
<p> <p className="flex gap-3">
<a <a
href={`https://www.google.com/maps/place/?q=place_id:${restaurant.google_place_id}`} href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name + (restaurant.address ? " " + restaurant.address : restaurant.region ? " " + restaurant.region.replace(/\|/g, " ") : ""))}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-orange-600 dark:text-orange-400 hover:underline text-xs" className="text-brand-600 dark:text-brand-400 hover:underline text-xs"
> >
Google Maps에서 Google Maps에서
</a> </a>
{(!restaurant.region || restaurant.region.split("|")[0] === "한국") && (
<a
href={`https://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`}
target="_blank"
rel="noopener noreferrer"
className="text-green-600 dark:text-green-400 hover:underline text-xs"
>
</a>
)}
</p> </p>
)} )}
</div> </div>
{restaurant.tabling_url && restaurant.tabling_url !== "NONE" && (
<a
href={restaurant.tabling_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 bg-rose-500 hover:bg-rose-600 text-white rounded-lg text-sm font-semibold transition-colors"
>
<span>T</span>
<span> </span>
</a>
)}
{restaurant.catchtable_url && restaurant.catchtable_url !== "NONE" && (
<a
href={restaurant.catchtable_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 bg-violet-500 hover:bg-violet-600 text-white rounded-lg text-sm font-semibold transition-colors"
>
<span>C</span>
<span> </span>
</a>
)}
<div> <div>
<h3 className="font-semibold text-sm mb-2 dark:text-gray-200"> </h3> <h3 className="font-semibold text-sm mb-2 dark:text-gray-200"> </h3>
{loading ? ( {loading ? (
@@ -161,7 +198,8 @@ export default function RestaurantDetail({
<div key={v.video_id} className="border dark:border-gray-700 rounded-lg p-3"> <div key={v.video_id} className="border dark:border-gray-700 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
{v.channel_name && ( {v.channel_name && (
<span className="inline-block px-1.5 py-0.5 bg-orange-50 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 rounded text-[10px] font-medium"> <span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400 rounded text-[10px] font-semibold">
<Icon name="play_circle" size={11} filled className="text-red-400" />
{v.channel_name} {v.channel_name}
</span> </span>
)} )}
@@ -175,8 +213,9 @@ export default function RestaurantDetail({
href={v.url} href={v.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="block text-sm font-medium text-orange-600 dark:text-orange-400 hover:underline" className="inline-flex items-center gap-1.5 text-sm font-medium text-red-600 dark:text-red-400 hover:underline"
> >
<Icon name="play_circle" size={16} filled className="flex-shrink-0" />
{v.title} {v.title}
</a> </a>
{v.foods_mentioned.length > 0 && ( {v.foods_mentioned.length > 0 && (
@@ -184,7 +223,7 @@ export default function RestaurantDetail({
{v.foods_mentioned.map((f, i) => ( {v.foods_mentioned.map((f, i) => (
<span <span
key={i} key={i}
className="px-2 py-0.5 bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded text-xs" className="px-2 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400 rounded text-xs"
> >
{f} {f}
</span> </span>
@@ -219,6 +258,7 @@ export default function RestaurantDetail({
)} )}
<ReviewSection restaurantId={restaurant.id} /> <ReviewSection restaurantId={restaurant.id} />
<MemoSection restaurantId={restaurant.id} />
</div> </div>
); );
} }

View File

@@ -2,6 +2,7 @@
import type { Restaurant } from "@/lib/api"; import type { Restaurant } from "@/lib/api";
import { getCuisineIcon } from "@/lib/cuisine-icons"; import { getCuisineIcon } from "@/lib/cuisine-icons";
import Icon from "@/components/Icon";
import { RestaurantListSkeleton } from "@/components/Skeleton"; import { RestaurantListSkeleton } from "@/components/Skeleton";
interface RestaurantListProps { interface RestaurantListProps {
@@ -39,59 +40,63 @@ export default function RestaurantList({
onClick={() => onSelect(r)} onClick={() => onSelect(r)}
className={`w-full text-left p-3 rounded-xl shadow-sm border transition-all hover:shadow-md hover:-translate-y-0.5 ${ className={`w-full text-left p-3 rounded-xl shadow-sm border transition-all hover:shadow-md hover:-translate-y-0.5 ${
selectedId === r.id selectedId === r.id
? "bg-orange-50 dark:bg-orange-900/20 border-orange-300 dark:border-orange-700 shadow-orange-100 dark:shadow-orange-900/10" ? "bg-brand-50 dark:bg-brand-900/20 border-brand-300 dark:border-brand-700 shadow-brand-100 dark:shadow-brand-900/10"
: "bg-white dark:bg-gray-900 border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800" : "bg-surface border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800"
}`} }`}
> >
<div className="flex items-start justify-between gap-2"> {/* 1줄: 식당명 + 지역 + 별점 (전체 폭) */}
<h4 className="font-semibold text-sm dark:text-gray-100"> <div className="flex items-baseline gap-1.5 flex-wrap">
<span className="mr-1">{getCuisineIcon(r.cuisine_type)}</span> <h4 className="font-bold text-[15px] text-gray-900 dark:text-gray-100 shrink-0">
<Icon name={getCuisineIcon(r.cuisine_type)} size={16} className="mr-0.5 text-brand-600" />
{r.name} {r.name}
</h4> </h4>
{r.region && (
<span className="text-[11px] text-gray-400 dark:text-gray-500 truncate">{r.region}</span>
)}
{r.rating && ( {r.rating && (
<span className="text-xs text-yellow-600 dark:text-yellow-400 font-medium whitespace-nowrap shrink-0"> <span className="text-xs text-yellow-600 dark:text-yellow-400 font-medium whitespace-nowrap shrink-0"> {r.rating}</span>
{r.rating}
</span>
)} )}
</div> </div>
<div className="flex flex-wrap gap-x-2 gap-y-0.5 mt-1.5 text-xs"> {/* 2줄: 종류/가격(왼) + 유튜브채널(우) */}
<div className="flex items-center gap-2 mt-1.5">
<div className="flex gap-x-2 text-xs flex-1 min-w-0">
{r.cuisine_type && ( {r.cuisine_type && (
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-600 dark:text-gray-400">{r.cuisine_type}</span> <span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400 shrink-0">{r.cuisine_type}</span>
)} )}
{r.price_range && ( {r.price_range && (
<span className="px-1.5 py-0.5 bg-gray-50 dark:bg-gray-800 rounded text-gray-600 dark:text-gray-400">{r.price_range}</span> <span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400 truncate min-w-0">{r.price_range}</span>
)} )}
</div> </div>
{r.region && ( {r.channels && r.channels.length > 0 && (
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500 truncate">{r.region}</p> <div className="shrink-0 flex flex-wrap gap-1 justify-end">
{r.channels.map((ch) => (
<span
key={ch}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 rounded-full text-[10px] font-medium truncate max-w-[120px]"
>
<Icon name="play_circle" size={11} filled className="shrink-0 text-red-400" />
<span className="truncate">{ch}</span>
</span>
))}
</div>
)} )}
</div>
{/* 3줄: 태그 (전체 폭) */}
{r.foods_mentioned && r.foods_mentioned.length > 0 && ( {r.foods_mentioned && r.foods_mentioned.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5"> <div className="flex flex-wrap gap-1 mt-1.5">
{r.foods_mentioned.slice(0, 5).map((f, i) => ( {r.foods_mentioned.slice(0, 5).map((f, i) => (
<span <span
key={i} key={i}
className="px-1.5 py-0.5 bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded text-[10px]" className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400 rounded text-[10px]"
> >
{f} {f}
</span> </span>
))} ))}
{r.foods_mentioned.length > 5 && ( {r.foods_mentioned.length > 5 && (
<span className="text-[10px] text-gray-400">+{r.foods_mentioned.length - 5}</span> <span className="text-[10px] text-gray-500">+{r.foods_mentioned.length - 5}</span>
)} )}
</div> </div>
)} )}
{r.channels && r.channels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{r.channels.map((ch) => (
<span
key={ch}
className="px-1.5 py-0.5 bg-orange-50 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 rounded-full text-[10px] font-medium"
>
{ch}
</span>
))}
</div>
)}
</button> </button>
))} ))}
</div> </div>

View File

@@ -124,7 +124,7 @@ function ReviewForm({
<button <button
type="submit" type="submit"
disabled={submitting} disabled={submitting}
className="px-3 py-1 bg-orange-500 dark:bg-orange-600 text-white text-sm rounded hover:bg-orange-600 dark:hover:bg-orange-500 disabled:opacity-50" className="px-3 py-1 bg-brand-500 dark:bg-brand-600 text-white text-sm rounded hover:bg-brand-600 dark:hover:bg-brand-500 disabled:opacity-50"
> >
{submitting ? "저장 중..." : submitLabel} {submitting ? "저장 중..." : submitLabel}
</button> </button>
@@ -225,7 +225,7 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
{user && !myReview && !showForm && ( {user && !myReview && !showForm && (
<button <button
onClick={() => setShowForm(true)} onClick={() => setShowForm(true)}
className="mb-3 px-3 py-1 bg-orange-500 dark:bg-orange-600 text-white text-sm rounded hover:bg-orange-600 dark:hover:bg-orange-500" className="mb-3 px-3 py-1 bg-brand-500 dark:bg-brand-600 text-white text-sm rounded hover:bg-brand-600 dark:hover:bg-brand-500"
> >
</button> </button>
@@ -234,6 +234,7 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
{showForm && ( {showForm && (
<div className="mb-3"> <div className="mb-3">
<ReviewForm <ReviewForm
initialDate={new Date().toISOString().slice(0, 10)}
onSubmit={handleCreate} onSubmit={handleCreate}
onCancel={() => setShowForm(false)} onCancel={() => setShowForm(false)}
submitLabel="작성" submitLabel="작성"

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import Icon from "@/components/Icon";
interface SearchBarProps { interface SearchBarProps {
onSearch: (query: string, mode: "keyword" | "semantic" | "hybrid") => void; onSearch: (query: string, mode: "keyword" | "semantic" | "hybrid") => void;
@@ -9,40 +10,31 @@ interface SearchBarProps {
export default function SearchBar({ onSearch, isLoading }: SearchBarProps) { export default function SearchBar({ onSearch, isLoading }: SearchBarProps) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [mode, setMode] = useState<"keyword" | "semantic" | "hybrid">("hybrid");
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (query.trim()) { if (query.trim()) {
onSearch(query.trim(), mode); onSearch(query.trim(), "hybrid");
} }
}; };
return ( return (
<form onSubmit={handleSubmit} className="flex gap-1.5 items-center"> <form onSubmit={handleSubmit} className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
<Icon name="search" size={16} />
</span>
<input <input
type="text" type="text"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="식당, 지역, 음식..." placeholder="식당, 지역, 음식 검색..."
className="flex-1 min-w-0 px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-400 text-sm bg-white dark:bg-gray-800 dark:text-gray-200 dark:placeholder-gray-500" className="w-full pl-9 pr-3 py-2 bg-gray-100 dark:bg-gray-800 border border-transparent focus:border-brand-400 focus:bg-surface rounded-xl text-sm outline-none transition-all dark:text-gray-200 dark:placeholder-gray-500"
/> />
<select {isLoading && (
value={mode} <div className="absolute right-3 top-1/2 -translate-y-1/2">
onChange={(e) => setMode(e.target.value as typeof mode)} <div className="w-4 h-4 border-2 border-brand-400 border-t-transparent rounded-full animate-spin" />
className="shrink-0 px-2 py-2 border border-gray-300 dark:border-gray-700 rounded-lg text-sm bg-white dark:bg-gray-800 dark:text-gray-300" </div>
> )}
<option value="hybrid"></option>
<option value="keyword"></option>
<option value="semantic"></option>
</select>
<button
type="submit"
disabled={isLoading || !query.trim()}
className="shrink-0 px-3 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 disabled:opacity-50 text-sm"
>
{isLoading ? "..." : "검색"}
</button>
</form> </form>
); );
} }

Binary file not shown.

View File

@@ -42,6 +42,8 @@ export interface Restaurant {
cuisine_type: string | null; cuisine_type: string | null;
price_range: string | null; price_range: string | null;
google_place_id: string | null; google_place_id: string | null;
tabling_url: string | null;
catchtable_url: string | null;
business_status: string | null; business_status: string | null;
rating: number | null; rating: number | null;
rating_count: number | null; rating_count: number | null;
@@ -68,6 +70,9 @@ export interface Channel {
channel_id: string; channel_id: string;
channel_name: string; channel_name: string;
title_filter: string | null; title_filter: string | null;
description: string | null;
tags: string | null;
sort_order: number | null;
video_count: number; video_count: number;
last_scanned_at: string | null; last_scanned_at: string | null;
} }
@@ -124,6 +129,17 @@ export interface Review {
user_avatar_url: string | null; user_avatar_url: string | null;
} }
export interface Memo {
id: string;
user_id: string;
restaurant_id: string;
rating: number | null;
memo_text: string | null;
visited_at: string | null;
created_at: string;
updated_at: string;
}
export interface DaemonConfig { export interface DaemonConfig {
scan_enabled: boolean; scan_enabled: boolean;
scan_interval_min: number; scan_interval_min: number;
@@ -251,6 +267,28 @@ export const api = {
); );
}, },
// Memos
getMemo(restaurantId: string) {
return fetchApi<Memo>(`/api/restaurants/${restaurantId}/memo`);
},
upsertMemo(restaurantId: string, data: { rating?: number; memo_text?: string; visited_at?: string }) {
return fetchApi<Memo>(`/api/restaurants/${restaurantId}/memo`, {
method: "POST",
body: JSON.stringify(data),
});
},
deleteMemo(restaurantId: string) {
return fetchApi<void>(`/api/restaurants/${restaurantId}/memo`, {
method: "DELETE",
});
},
getMyMemos() {
return fetchApi<(Memo & { restaurant_name: string | null })[]>("/api/users/me/memos");
},
// Stats // Stats
recordVisit() { recordVisit() {
return fetchApi<{ ok: boolean }>("/api/stats/visit", { method: "POST" }); return fetchApi<{ ok: boolean }>("/api/stats/visit", { method: "POST" });
@@ -276,6 +314,7 @@ export const api = {
created_at: string | null; created_at: string | null;
favorite_count: number; favorite_count: number;
review_count: number; review_count: number;
memo_count: number;
}[]; }[];
total: number; total: number;
}>(`/api/admin/users${qs ? `?${qs}` : ""}`); }>(`/api/admin/users${qs ? `?${qs}` : ""}`);
@@ -310,6 +349,20 @@ export const api = {
>(`/api/admin/users/${userId}/reviews`); >(`/api/admin/users/${userId}/reviews`);
}, },
getAdminUserMemos(userId: string) {
return fetchApi<
{
id: string;
restaurant_id: string;
rating: number | null;
memo_text: string | null;
visited_at: string | null;
created_at: string;
restaurant_name: string | null;
}[]
>(`/api/admin/users/${userId}/memos`);
},
// Admin // Admin
addChannel(channelId: string, channelName: string, titleFilter?: string) { addChannel(channelId: string, channelName: string, titleFilter?: string) {
return fetchApi<{ id: string; channel_id: string }>("/api/channels", { return fetchApi<{ id: string; channel_id: string }>("/api/channels", {
@@ -322,6 +375,39 @@ export const api = {
}); });
}, },
searchTabling(restaurantId: string) {
return fetchApi<{ title: string; url: string }[]>(
`/api/restaurants/${restaurantId}/tabling-search`
);
},
setTablingUrl(restaurantId: string, tablingUrl: string) {
return fetchApi<{ ok: boolean }>(
`/api/restaurants/${restaurantId}/tabling-url`,
{ method: "PUT", body: JSON.stringify({ tabling_url: tablingUrl }) }
);
},
searchCatchtable(restaurantId: string) {
return fetchApi<{ title: string; url: string }[]>(
`/api/restaurants/${restaurantId}/catchtable-search`
);
},
setCatchtableUrl(restaurantId: string, catchtableUrl: string) {
return fetchApi<{ ok: boolean }>(
`/api/restaurants/${restaurantId}/catchtable-url`,
{ method: "PUT", body: JSON.stringify({ catchtable_url: catchtableUrl }) }
);
},
updateChannel(id: string, data: { description?: string; tags?: string; sort_order?: number }) {
return fetchApi<{ ok: boolean }>(`/api/channels/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
},
deleteChannel(channelId: string) { deleteChannel(channelId: string) {
return fetchApi<{ ok: boolean }>(`/api/channels/${channelId}`, { return fetchApi<{ ok: boolean }>(`/api/channels/${channelId}`, {
method: "DELETE", method: "DELETE",
@@ -381,6 +467,13 @@ export const api = {
); );
}, },
uploadTranscript(videoDbId: string, text: string, source: string = "browser") {
return fetchApi<{ ok: boolean; length: number; source: string }>(
`/api/videos/${videoDbId}/upload-transcript`,
{ method: "POST", body: JSON.stringify({ text, source }) }
);
},
triggerProcessing(limit: number = 5) { triggerProcessing(limit: number = 5) {
return fetchApi<{ restaurants_extracted: number }>( return fetchApi<{ restaurants_extracted: number }>(
`/api/videos/process?limit=${limit}`, `/api/videos/process?limit=${limit}`,
@@ -462,6 +555,12 @@ export const api = {
}); });
}, },
flushCache() {
return fetchApi<{ ok: boolean }>("/api/admin/cache-flush", {
method: "POST",
});
},
runDaemonProcess(limit: number = 10) { runDaemonProcess(limit: number = 10) {
return fetchApi<{ ok: boolean; restaurants_extracted: number }>( return fetchApi<{ ok: boolean; restaurants_extracted: number }>(
`/api/daemon/run/process?limit=${limit}`, `/api/daemon/run/process?limit=${limit}`,

View File

@@ -1,49 +1,154 @@
/** /**
* Cuisine type → icon mapping. * Cuisine type → icon mapping.
* Material Symbols icon name for RestaurantList (existing usage).
* Tabler icon component name for genre card chips (home tab).
*
* Works with "대분류|소분류" format (e.g. "한식|국밥/해장국"). * Works with "대분류|소분류" format (e.g. "한식|국밥/해장국").
*/ */
// ── Material Symbols (for RestaurantList etc.) ──
const CUISINE_ICON_MAP: Record<string, string> = { const CUISINE_ICON_MAP: Record<string, string> = {
"한식": "🍚", "한식": "rice_bowl",
"일식": "🍣", "일식": "set_meal",
"중식": "🥟", "중식": "skillet",
"양식": "🍝", "양식": "dinner_dining",
"아시아": "🍜", "아시아": "restaurant",
"기타": "🍴", "기타": "flatware",
}; };
// Sub-category overrides for more specific icons
const SUB_ICON_RULES: { keyword: string; icon: string }[] = [ const SUB_ICON_RULES: { keyword: string; icon: string }[] = [
{ keyword: "회/횟집", icon: "🐟" }, { keyword: "백반/한정식", icon: "rice_bowl" },
{ keyword: "해산물", icon: "🦐" }, { keyword: "국밥/해장국", icon: "soup_kitchen" },
{ keyword: "삼겹살/돼지구이", icon: "🥩" }, { keyword: "찌개/전골/탕", icon: "outdoor_grill" },
{ keyword: "소고기/한우구이", icon: "🥩" }, { keyword: "삼겹살/돼지구이", icon: "kebab_dining" },
{ keyword: "곱창/막창", icon: "🥩" }, { keyword: "소고기/한우구이", icon: "local_fire_department" },
{ keyword: "닭/오리구이", icon: "🍗" }, { keyword: "곱창/막창", icon: "local_fire_department" },
{ keyword: "스테이크", icon: "🥩" }, { keyword: "닭/오리구이", icon: "takeout_dining" },
{ keyword: "햄버거", icon: "🍔" }, { keyword: "족발/보쌈", icon: "stockpot" },
{ keyword: "피자", icon: "🍕" }, { keyword: "회/횟집", icon: "phishing" },
{ keyword: "카페/디저트", icon: "" }, { keyword: "해산물", icon: "set_meal" },
{ keyword: "베이커리", icon: "🥐" }, { keyword: "분식", icon: "egg_alt" },
{ keyword: "치킨", icon: "🍗" }, { keyword: "", icon: "ramen_dining" },
{ keyword: "주점/포차", icon: "🍺" }, { keyword: "죽/죽집", icon: "soup_kitchen" },
{ keyword: "이자카야", icon: "🍶" }, { keyword: "순대/순대국", icon: "soup_kitchen" },
{ keyword: "라멘", icon: "🍜" }, { keyword: "장어/민물", icon: "phishing" },
{ keyword: "국밥/해장국", icon: "🍲" }, { keyword: "주점/포차", icon: "local_bar" },
{ keyword: "분식", icon: "🍜" }, { keyword: "파인다이닝/코스", icon: "auto_awesome" },
{ keyword: "스시/오마카세", icon: "set_meal" },
{ keyword: "라멘", icon: "ramen_dining" },
{ keyword: "돈카츠", icon: "lunch_dining" },
{ keyword: "텐동/튀김", icon: "tapas" },
{ keyword: "이자카야", icon: "sake" },
{ keyword: "야키니쿠", icon: "kebab_dining" },
{ keyword: "카레", icon: "skillet" },
{ keyword: "소바/우동", icon: "ramen_dining" },
{ keyword: "중화요리", icon: "skillet" },
{ keyword: "마라/훠궈", icon: "outdoor_grill" },
{ keyword: "딤섬/만두", icon: "egg_alt" },
{ keyword: "양꼬치", icon: "kebab_dining" },
{ keyword: "파스타/이탈리안", icon: "dinner_dining" },
{ keyword: "스테이크", icon: "restaurant" },
{ keyword: "햄버거", icon: "lunch_dining" },
{ keyword: "피자", icon: "local_pizza" },
{ keyword: "프렌치", icon: "auto_awesome" },
{ keyword: "바베큐", icon: "outdoor_grill" },
{ keyword: "브런치", icon: "brunch_dining" },
{ keyword: "비건/샐러드", icon: "eco" },
{ keyword: "베트남", icon: "ramen_dining" },
{ keyword: "태국", icon: "restaurant" },
{ keyword: "인도/중동", icon: "skillet" },
{ keyword: "동남아기타", icon: "restaurant" },
{ keyword: "치킨", icon: "takeout_dining" },
{ keyword: "카페/디저트", icon: "coffee" },
{ keyword: "베이커리", icon: "bakery_dining" },
{ keyword: "뷔페", icon: "brunch_dining" },
{ keyword: "퓨전", icon: "auto_awesome" },
]; ];
const DEFAULT_ICON = "🍴"; const DEFAULT_ICON = "flatware";
export function getCuisineIcon(cuisineType: string | null | undefined): string { export function getCuisineIcon(cuisineType: string | null | undefined): string {
if (!cuisineType) return DEFAULT_ICON; if (!cuisineType) return DEFAULT_ICON;
// Check sub-category first
for (const rule of SUB_ICON_RULES) { for (const rule of SUB_ICON_RULES) {
if (cuisineType.includes(rule.keyword)) return rule.icon; if (cuisineType.includes(rule.keyword)) return rule.icon;
} }
// Fall back to main category (prefix before |)
const main = cuisineType.split("|")[0]; const main = cuisineType.split("|")[0];
return CUISINE_ICON_MAP[main] || DEFAULT_ICON; return CUISINE_ICON_MAP[main] || DEFAULT_ICON;
} }
// ── Tabler Icons (for genre card chips) ──
// Returns Tabler icon component name (PascalCase without "Icon" prefix)
const TABLER_CUISINE_MAP: Record<string, string> = {
"한식": "BowlChopsticks",
"일식": "Fish",
"중식": "Soup",
"양식": "Pizza",
"아시아": "BowlSpoon",
"기타": "Cookie",
};
const TABLER_SUB_RULES: { keyword: string; icon: string }[] = [
// 한식
{ keyword: "백반/한정식", icon: "BowlChopsticks" },
{ keyword: "국밥/해장국", icon: "Soup" },
{ keyword: "찌개/전골/탕", icon: "Cooker" },
{ keyword: "삼겹살/돼지구이", icon: "Meat" },
{ keyword: "소고기/한우구이", icon: "Grill" },
{ keyword: "곱창/막창", icon: "GrillFork" },
{ keyword: "닭/오리구이", icon: "Meat" },
{ keyword: "족발/보쌈", icon: "Meat" },
{ keyword: "회/횟집", icon: "Fish" },
{ keyword: "해산물", icon: "Fish" },
{ keyword: "분식", icon: "EggFried" },
{ keyword: "면", icon: "BowlChopsticks" },
{ keyword: "죽/죽집", icon: "BowlSpoon" },
{ keyword: "순대/순대국", icon: "Soup" },
{ keyword: "장어/민물", icon: "Fish" },
{ keyword: "주점/포차", icon: "Beer" },
{ keyword: "파인다이닝/코스", icon: "GlassChampagne" },
// 일식
{ keyword: "스시/오마카세", icon: "Fish" },
{ keyword: "라멘", icon: "Soup" },
{ keyword: "돈카츠", icon: "Meat" },
{ keyword: "텐동/튀김", icon: "EggFried" },
{ keyword: "이자카야", icon: "GlassCocktail" },
{ keyword: "야키니쿠", icon: "Grill" },
{ keyword: "카레", icon: "BowlSpoon" },
{ keyword: "소바/우동", icon: "BowlChopsticks" },
// 중식
{ keyword: "중화요리", icon: "Soup" },
{ keyword: "마라/훠궈", icon: "Pepper" },
{ keyword: "딤섬/만두", icon: "Egg" },
{ keyword: "양꼬치", icon: "Grill" },
// 양식
{ keyword: "파스타/이탈리안", icon: "BowlSpoon" },
{ keyword: "스테이크", icon: "Meat" },
{ keyword: "햄버거", icon: "Burger" },
{ keyword: "피자", icon: "Pizza" },
{ keyword: "프렌치", icon: "GlassChampagne" },
{ keyword: "바베큐", icon: "GrillSpatula" },
{ keyword: "브런치", icon: "EggFried" },
{ keyword: "비건/샐러드", icon: "Salad" },
// 아시아
{ keyword: "베트남", icon: "BowlChopsticks" },
{ keyword: "태국", icon: "Pepper" },
{ keyword: "인도/중동", icon: "BowlSpoon" },
{ keyword: "동남아기타", icon: "BowlSpoon" },
// 기타
{ keyword: "치킨", icon: "Meat" },
{ keyword: "카페/디저트", icon: "Coffee" },
{ keyword: "베이커리", icon: "Bread" },
{ keyword: "뷔페", icon: "Cheese" },
{ keyword: "퓨전", icon: "Cookie" },
];
export function getTablerCuisineIcon(cuisineType: string | null | undefined): string {
if (!cuisineType) return "Bowl";
for (const rule of TABLER_SUB_RULES) {
if (cuisineType.includes(rule.keyword)) return rule.icon;
}
const main = cuisineType.split("|")[0];
return TABLER_CUISINE_MAP[main] || "Bowl";
}

View File

@@ -16,7 +16,6 @@ spec:
tls: tls:
- hosts: - hosts:
- www.tasteby.net - www.tasteby.net
- tasteby.net
secretName: tasteby-tls secretName: tasteby-tls
rules: rules:
- host: www.tasteby.net - host: www.tasteby.net

View File

@@ -15,7 +15,7 @@ spec:
spec: spec:
containers: containers:
- name: redis - name: redis
image: redis:7-alpine image: docker.io/library/redis:7-alpine
ports: ports:
- containerPort: 6379 - containerPort: 6379
resources: resources:

View File

@@ -4,6 +4,47 @@ server {
return 301 https://www.tasteby.net$request_uri; return 301 https://www.tasteby.net$request_uri;
} }
server {
listen 80;
server_name dev.tasteby.net;
return 301 https://dev.tasteby.net$request_uri;
}
server {
listen 443 ssl http2;
server_name dev.tasteby.net;
ssl_certificate /etc/letsencrypt/live/dev.tasteby.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev.tasteby.net/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Frontend (Next.js)
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Backend API
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
proxy_connect_timeout 75;
proxy_send_timeout 300;
}
}
server { server {
listen 443 ssl http2; listen 443 ssl http2;
server_name tasteby.net; server_name tasteby.net;