31 Commits

Author SHA1 Message Date
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
joungmin
293c59060c Add environment management guide for dev/prod separation
- Document all env vars with dev vs prod mapping
- Explain K8s Service DNS for Redis connectivity
- Detail Secret/ConfigMap strategy for sensitive config
- Cover OCI auth and Oracle Wallet volume mount differences

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:02:59 +09:00
joungmin
ff4e8d742d Add cuisine subcategory filter, fix remap logic, and add OKE deployment manifests
- Add 파인다이닝/코스 cuisine type to 한식/일식/중식/양식 categories
- Change cuisine filter from flat list to grouped optgroup with subcategories
- Fix remap-foods/remap-cuisine: add jdbcType=CLOB, fix CLOB LISTAGG,
  improve retry logic (3 attempts, batch size 5), add error logging
- Add OKE deployment: Dockerfiles, K8s manifests, deploy.sh, deployment guide
- Add Next.js standalone output for Docker builds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:58:09 +09:00
joungmin
69e1882c2b Add video management endpoints and fix LLM extraction pipeline
- Add missing endpoints: fetch-transcript, extract, bulk-extract/pending,
  bulk-transcript/pending, manual restaurant add, restaurant update
- Add OCI HTTP client dependency (jersey3) for GenAI SDK compatibility
- Fix Oracle null parameter ORA-17004 with jdbcType=CLOB in MyBatis
- Fix evaluation IS JSON constraint by storing as valid JSON
- Add @JsonProperty("transcript") for frontend compatibility
- Add Korean-only rule to LLM extraction prompt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:08:40 +09:00
joungmin
16bd83c570 Fix login 401, admin permission, video links serialization, and admin UI styling
- Fix UserInfo boolean field naming (isAdmin → admin) for proper Jackson/MyBatis mapping
- Configure Google OAuth audience with actual client ID to fix token verification
- Parse CLOB fields and convert Oracle TIMESTAMP in restaurant video links API
- Add explicit bg-white/text-gray-900 to admin page inputs, selects, and table headers
- Add keyPrefix to RestaurantList to avoid duplicate React keys across desktop/mobile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:41:57 +09:00
joungmin
c16add08c3 Migrate to MyBatis with proper Controller→Service→Mapper layering
- Add MyBatis Spring Boot Starter with XML mappers and domain classes
- Create 9 mapper interfaces + XML: Restaurant, Video, Channel, Review,
  User, Stats, DaemonConfig, Search, Vector
- Create 10 domain classes with Lombok: Restaurant, VideoSummary,
  VideoDetail, VideoRestaurantLink, Channel, Review, UserInfo,
  DaemonConfig, SiteVisitStats, VectorSearchResult
- Create 7 new service classes: RestaurantService, VideoService,
  ChannelService, ReviewService, UserService, StatsService,
  DaemonConfigService
- Refactor all controllers to be thin (HTTP + auth only), delegating
  business logic to services
- Refactor SearchService, PipelineService, DaemonScheduler, AuthService,
  YouTubeService to use mappers/services instead of JDBC/repositories
- Add Jackson SNAKE_CASE property naming for consistent API responses
- Add ClobTypeHandler for Oracle CLOB→String in MyBatis
- Add IdGenerator utility for centralized UUID generation
- Delete old repository/ package (6 files), JdbcConfig, LowerCaseKeyAdvice
- VectorService retains JDBC for Oracle VECTOR type support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:13:44 +09:00
joungmin
91d0ad4598 Fix Oracle uppercase column keys: return lowercase in all API responses
- Add LowerCaseKeyAdvice (ResponseBodyAdvice) to auto-convert Map keys
- Add LowerCaseJdbcTemplate with overridden getColumnMapRowMapper
- Update all repository/service code to use lowercase key access
- Add lowerKeys utility to JsonUtil

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:38:43 +09:00
joungmin
a844fd44cc Fix CORS: allow tasteby.net origin and integrate with Spring Security
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:30:54 +09:00
joungmin
6d05be2331 Migrate backend from Python to Java Spring Boot
- Full Java 21 + Spring Boot 3.3 backend with Virtual Threads
- HikariCP connection pool for Oracle ADB
- JWT auth, Redis caching, OCI GenAI integration
- YouTube transcript extraction via API + Playwright browser fallback
- SSE streaming for bulk operations
- Scheduled daemon for channel scanning/video processing
- Mobile UI: collapse restaurant list to single row on selection
- Switch PM2 ecosystem config to Java backend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:26:32 +09:00
joungmin
161b1383be Double mobile map height in list mode (35vh → 70vh)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:18:01 +09:00
joungmin
d39b3b8fea Disable map/satellite toggle on Google Maps
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:17:19 +09:00
joungmin
b3923dcc72 Move channel legend to bottom-left of map
Also added dark mode support for the legend overlay.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:16:40 +09:00
joungmin
6223691b33 Add dark mode with system preference auto-detection
All user-facing components now support dark mode via prefers-color-scheme.
- Dark backgrounds: gray-950/900/800
- Dark text: gray-100/200/300/400
- Orange brand colors adapt with darker tints
- Glass effects work in both modes
- Skeletons, cards, filters, bottom sheet all themed
- Google Maps InfoWindow stays light (maps don't support dark)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:14:29 +09:00
joungmin
99660bf07b Apply glassmorphism effect to overlays and panels
- Header: semi-transparent white with backdrop blur
- Visit counter: frosted glass instead of dark overlay
- Mobile filter panel: translucent with blur
- BottomSheet: frosted glass background
- Footer: subtle glass effect

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:08:21 +09:00
joungmin
d1ef156f44 Add icon+label combos to all filter buttons and dropdowns
Filters: 📺 채널, 🍽 장르, 💰 가격, 🌍 나라, 🏙 시/도, 🏘 구/군
Buttons: 📍 영역, 🗺 지도/☰ 리스트, 🔽 필터, ♥ 찜, ✎ 리뷰

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:04:08 +09:00
joungmin
4a1c8cf1cd Unify color scheme: orange primary, rose accent, gray neutral
- Primary (orange): search button, active filters, selected cards,
  channel tags, links, input focus ring
- Accent (rose): favorites/heart only
- Neutral (gray): price tags, inactive buttons
- Semantic colors preserved: red for 폐업, yellow for 임시휴업

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:00:13 +09:00
joungmin
2cd72d660a Move region filters (country/city/district) to second row on desktop
Reduces first row clutter. Added divider between region and toggle buttons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:57:14 +09:00
joungmin
17489ad9b0 Improve whitespace and spacing across header, filters, and buttons
Increased padding, wider gaps between elements, rounded-lg corners,
and more breathing room in mobile filter panel for better visual rhythm.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:55:40 +09:00
joungmin
08ea282baf Redesign footer with playful cartoon-style animations
Larger icon with border, hover scale+rotate effect, ping dot indicator,
gradient background, and smooth transitions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:09:56 +09:00
joungmin
237c982e6c Add company icon to footer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:29:21 +09:00
joungmin
758d87842b Add footer with company name (SDJ Labs Co., Ltd.)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:10:29 +09:00
joungmin
d4d516a375 Fix remap-foods: add missing db_conn import
Generator was failing silently due to undefined db_conn reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:23:08 +09:00
joungmin
54d21afd52 Add food tag remap feature and show menu tags in restaurant cards
- LLM extraction prompt: foods_mentioned max 10, Korean only, prioritized
- New /remap-foods API endpoint for bulk LLM re-extraction
- Admin UI: "메뉴태그 재생성" button with SSE progress bar
- Backend: attach foods_mentioned to restaurant list API response
- Restaurant cards: display food tags (orange, max 5 visible)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:21:05 +09:00
joungmin
a5b3598f8a Redesign restaurant list with card UI
Rounded corners, shadows, hover lift effect, rating display,
and color-coded cuisine/price tags for better visual hierarchy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:12:24 +09:00
joungmin
f54da90b5f Add UX/design improvement guide document
Tracks planned UI/UX improvements with priorities and completion status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:10:03 +09:00
joungmin
4d09be2419 Add skeleton loading UI for better perceived performance
Replace "로딩 중..." text with animated skeleton placeholders in
RestaurantList, RestaurantDetail, and ReviewSection components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:08:03 +09:00
joungmin
6c47d3c57d Backend enhancements: auth, channels, restaurants, daemon improvements
- Add admin auth dependency and role checks
- Expand channel and restaurant API routes
- Improve YouTube transcript fetching
- Enhance daemon worker with better error handling and scheduling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:59:22 +09:00
joungmin
d6afb62c18 Mobile header: split search and toolbar into two rows
- Row 1: search bar (full width)
- Row 2: map/list toggle, filter toggle with badge, favorites, reviews, count
- Moved favorites/reviews buttons out of collapsible filter panel to toolbar row

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:56:19 +09:00
joungmin
2bddb0f764 UX improvements: mobile bottom sheet, cuisine taxonomy, search enhancements
- Add BottomSheet component for Google Maps-style restaurant detail on mobile
  (3-snap drag: 40%/55%/92%, velocity-based close, backdrop overlay)
- Mobile map mode now full-screen with bottom sheet overlay for details
- Collapsible filter panel on mobile with active filter badge count
- Standardized cuisine taxonomy (46 categories: 한식|국밥, 일식|스시 etc.)
  with LLM remap endpoint and admin UI button
- Enhanced search: keyword search now includes foods_mentioned + video title
- Search results include channels array for frontend filtering
- Channel filter moved to frontend filteredRestaurants (not API-level)
- LLM extraction prompt updated for pipe-delimited region + cuisine taxonomy
- Vector rebuild endpoint with rich JSON chunks per restaurant
- Geolocation-based auto region selection on page load
- Desktop filters split into two clean rows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:54:28 +09:00
133 changed files with 10390 additions and 449 deletions

7
.gitignore vendored
View File

@@ -6,3 +6,10 @@ node_modules/
.next/ .next/
.env.local .env.local
*.log *.log
# Java backend
backend-java/build/
backend-java/.gradle/
# K8s secrets (never commit)
k8s/secrets.yaml

View File

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

16
backend-java/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
# ── Build stage ──
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY gradlew settings.gradle build.gradle ./
COPY gradle/ gradle/
RUN chmod +x gradlew && ./gradlew dependencies --no-daemon || true
COPY src/ src/
RUN ./gradlew bootJar -x test --no-daemon
# ── Runtime stage ──
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8000
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

71
backend-java/build.gradle Normal file
View File

@@ -0,0 +1,71 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.5'
id 'io.spring.dependency-management' version '1.1.6'
}
group = 'com.tasteby'
version = '0.1.0'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
// Spring Boot
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-security'
// MyBatis
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.4'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-validation'
// Oracle JDBC + Security (Wallet support for Oracle ADB)
implementation 'com.oracle.database.jdbc:ojdbc11:23.7.0.25.01'
implementation 'com.oracle.database.security:oraclepki:23.7.0.25.01'
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
// Google OAuth2 token verification
implementation 'com.google.api-client:google-api-client:2.7.0'
// OCI SDK (GenAI for LLM + Embeddings)
implementation 'com.oracle.oci.sdk:oci-java-sdk-generativeaiinference:3.49.0'
implementation 'com.oracle.oci.sdk:oci-java-sdk-common:3.49.0'
implementation 'com.oracle.oci.sdk:oci-java-sdk-common-httpclient-jersey3:3.49.0'
// Jackson for JSON
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
// YouTube Transcript API (Java port of youtube-transcript-api)
implementation 'io.github.thoroldvix:youtube-transcript-api:0.4.0'
// Playwright (browser-based transcript fallback)
implementation 'com.microsoft.playwright:playwright:1.49.0'
// HTTP client for YouTube/Google APIs
implementation 'org.springframework.boot:spring-boot-starter-webflux'
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
backend-java/gradlew vendored Executable file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
backend-java/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1 @@
rootProject.name = 'tasteby-api'

View File

@@ -0,0 +1,15 @@
package com.tasteby;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableAsync
@EnableScheduling
public class TastebyApplication {
public static void main(String[] args) {
SpringApplication.run(TastebyApplication.class, args);
}
}

View File

@@ -0,0 +1,50 @@
package com.tasteby.config;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;
import java.io.Reader;
import java.sql.*;
@MappedTypes(String.class)
@MappedJdbcTypes(JdbcType.CLOB)
public class ClobTypeHandler extends BaseTypeHandler<String> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType)
throws SQLException {
ps.setString(i, parameter);
}
@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
return clobToString(rs.getClob(columnName));
}
@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return clobToString(rs.getClob(columnIndex));
}
@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return clobToString(cs.getClob(columnIndex));
}
private String clobToString(Clob clob) {
if (clob == null) return null;
try (Reader reader = clob.getCharacterStream()) {
StringBuilder sb = new StringBuilder();
char[] buf = new char[4096];
int len;
while ((len = reader.read(buf)) != -1) {
sb.append(buf, 0, len);
}
return sb.toString();
} catch (Exception e) {
return null;
}
}
}

View File

@@ -0,0 +1,21 @@
package com.tasteby.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PostConstruct;
@Configuration
public class DataSourceConfig {
@Value("${app.oracle.wallet-path:}")
private String walletPath;
@PostConstruct
public void configureWallet() {
if (walletPath != null && !walletPath.isBlank()) {
System.setProperty("oracle.net.tns_admin", walletPath);
System.setProperty("oracle.net.wallet_location", walletPath);
}
}
}

View File

@@ -0,0 +1,30 @@
package com.tasteby.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<Map<String, Object>> handleStatus(ResponseStatusException ex) {
return ResponseEntity.status(ex.getStatusCode())
.body(Map.of("detail", ex.getReason() != null ? ex.getReason() : "Error"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleGeneral(Exception ex) {
log.error("Unhandled exception", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("detail", "Internal server error"));
}
}

View File

@@ -0,0 +1,15 @@
package com.tasteby.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
@Configuration
public class RedisConfig {
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
return new StringRedisTemplate(connectionFactory);
}
}

View File

@@ -0,0 +1,47 @@
package com.tasteby.config;
import com.tasteby.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtFilter;
public SecurityConfig(JwtAuthenticationFilter jwtFilter) {
this.jwtFilter = jwtFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> {})
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// Public endpoints
.requestMatchers("/api/health").permitAll()
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/restaurants/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels").permitAll()
.requestMatchers(HttpMethod.GET, "/api/search").permitAll()
.requestMatchers(HttpMethod.GET, "/api/restaurants/*/reviews").permitAll()
.requestMatchers("/api/stats/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/daemon/config").permitAll()
// Everything else requires authentication (controller-level admin checks)
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}

View File

@@ -0,0 +1,32 @@
package com.tasteby.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.Arrays;
import java.util.List;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Value("${app.cors.allowed-origins}")
private String allowedOrigins;
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}

View File

@@ -0,0 +1,42 @@
package com.tasteby.controller;
import com.tasteby.domain.Restaurant;
import com.tasteby.domain.Review;
import com.tasteby.service.ReviewService;
import com.tasteby.service.UserService;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/users")
public class AdminUserController {
private final UserService userService;
private final ReviewService reviewService;
public AdminUserController(UserService userService, ReviewService reviewService) {
this.userService = userService;
this.reviewService = reviewService;
}
@GetMapping
public Map<String, Object> listUsers(
@RequestParam(defaultValue = "50") int limit,
@RequestParam(defaultValue = "0") int offset) {
var users = userService.findAllWithCounts(limit, offset);
int total = userService.countAll();
return Map.of("users", users, "total", total);
}
@GetMapping("/{userId}/favorites")
public List<Restaurant> userFavorites(@PathVariable String userId) {
return reviewService.getUserFavorites(userId);
}
@GetMapping("/{userId}/reviews")
public List<Review> userReviews(@PathVariable String userId) {
return reviewService.findByUser(userId, 100, 0);
}
}

View File

@@ -0,0 +1,31 @@
package com.tasteby.controller;
import com.tasteby.domain.UserInfo;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.AuthService;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/google")
public Map<String, Object> loginGoogle(@RequestBody Map<String, String> body) {
String idToken = body.get("id_token");
return authService.loginGoogle(idToken);
}
@GetMapping("/me")
public UserInfo me() {
String userId = AuthUtil.getUserId();
return authService.getCurrentUser(userId);
}
}

View File

@@ -0,0 +1,72 @@
package com.tasteby.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tasteby.domain.Channel;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.CacheService;
import com.tasteby.service.ChannelService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/channels")
public class ChannelController {
private final ChannelService channelService;
private final CacheService cache;
private final ObjectMapper objectMapper;
public ChannelController(ChannelService channelService, CacheService cache, ObjectMapper objectMapper) {
this.channelService = channelService;
this.cache = cache;
this.objectMapper = objectMapper;
}
@GetMapping
public List<Channel> list() {
String key = cache.makeKey("channels");
String cached = cache.getRaw(key);
if (cached != null) {
try {
return objectMapper.readValue(cached, new TypeReference<List<Channel>>() {});
} catch (Exception ignored) {}
}
var result = channelService.findAllActive();
cache.set(key, result);
return result;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Map<String, Object> create(@RequestBody Map<String, String> body) {
AuthUtil.requireAdmin();
String channelId = body.get("channel_id");
String channelName = body.get("channel_name");
String titleFilter = body.get("title_filter");
try {
String id = channelService.create(channelId, channelName, titleFilter);
cache.flush();
return Map.of("id", id, "channel_id", channelId);
} catch (Exception e) {
if (e.getMessage() != null && e.getMessage().toUpperCase().contains("UQ_CHANNELS_CID")) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Channel already exists");
}
throw e;
}
}
@DeleteMapping("/{channelId}")
public Map<String, Object> delete(@PathVariable String channelId) {
AuthUtil.requireAdmin();
if (!channelService.deactivate(channelId)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Channel not found");
}
cache.flush();
return Map.of("ok", true);
}
}

View File

@@ -0,0 +1,32 @@
package com.tasteby.controller;
import com.tasteby.domain.DaemonConfig;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.DaemonConfigService;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/daemon")
public class DaemonController {
private final DaemonConfigService daemonConfigService;
public DaemonController(DaemonConfigService daemonConfigService) {
this.daemonConfigService = daemonConfigService;
}
@GetMapping("/config")
public DaemonConfig getConfig() {
DaemonConfig config = daemonConfigService.getConfig();
return config != null ? config : DaemonConfig.builder().build();
}
@PutMapping("/config")
public Map<String, Object> updateConfig(@RequestBody Map<String, Object> body) {
AuthUtil.requireAdmin();
daemonConfigService.updateConfig(body);
return Map.of("ok", true);
}
}

View File

@@ -0,0 +1,15 @@
package com.tasteby.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class HealthController {
@GetMapping("/api/health")
public Map<String, String> health() {
return Map.of("status", "ok");
}
}

View File

@@ -0,0 +1,101 @@
package com.tasteby.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tasteby.domain.Restaurant;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.CacheService;
import com.tasteby.service.RestaurantService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/restaurants")
public class RestaurantController {
private final RestaurantService restaurantService;
private final CacheService cache;
private final ObjectMapper objectMapper;
public RestaurantController(RestaurantService restaurantService, CacheService cache, ObjectMapper objectMapper) {
this.restaurantService = restaurantService;
this.cache = cache;
this.objectMapper = objectMapper;
}
@GetMapping
public List<Restaurant> list(
@RequestParam(defaultValue = "100") int limit,
@RequestParam(defaultValue = "0") int offset,
@RequestParam(required = false) String cuisine,
@RequestParam(required = false) String region,
@RequestParam(required = false) String channel) {
if (limit > 500) limit = 500;
String key = cache.makeKey("restaurants", "l=" + limit, "o=" + offset,
"c=" + cuisine, "r=" + region, "ch=" + channel);
String cached = cache.getRaw(key);
if (cached != null) {
try {
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
} catch (Exception ignored) {}
}
var result = restaurantService.findAll(limit, offset, cuisine, region, channel);
cache.set(key, result);
return result;
}
@GetMapping("/{id}")
public Restaurant get(@PathVariable String id) {
String key = cache.makeKey("restaurant", id);
String cached = cache.getRaw(key);
if (cached != null) {
try {
return objectMapper.readValue(cached, Restaurant.class);
} catch (Exception ignored) {}
}
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
cache.set(key, r);
return r;
}
@PutMapping("/{id}")
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
AuthUtil.requireAdmin();
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
restaurantService.update(id, body);
cache.flush();
return Map.of("ok", true);
}
@DeleteMapping("/{id}")
public Map<String, Object> delete(@PathVariable String id) {
AuthUtil.requireAdmin();
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
restaurantService.delete(id);
cache.flush();
return Map.of("ok", true);
}
@GetMapping("/{id}/videos")
public List<Map<String, Object>> videos(@PathVariable String id) {
String key = cache.makeKey("restaurant_videos", id);
String cached = cache.getRaw(key);
if (cached != null) {
try {
return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {});
} catch (Exception ignored) {}
}
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
var result = restaurantService.findVideoLinks(id);
cache.set(key, result);
return result;
}
}

View File

@@ -0,0 +1,97 @@
package com.tasteby.controller;
import com.tasteby.domain.Restaurant;
import com.tasteby.domain.Review;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.ReviewService;
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 ReviewController {
private final ReviewService reviewService;
public ReviewController(ReviewService reviewService) {
this.reviewService = reviewService;
}
@GetMapping("/restaurants/{restaurantId}/reviews")
public Map<String, Object> listRestaurantReviews(
@PathVariable String restaurantId,
@RequestParam(defaultValue = "20") int limit,
@RequestParam(defaultValue = "0") int offset) {
var reviews = reviewService.findByRestaurant(restaurantId, limit, offset);
var stats = reviewService.getAvgRating(restaurantId);
return Map.of("reviews", reviews, "avg_rating", stats.get("avg_rating"),
"review_count", stats.get("review_count"));
}
@PostMapping("/restaurants/{restaurantId}/reviews")
@ResponseStatus(HttpStatus.CREATED)
public Review createReview(
@PathVariable String restaurantId,
@RequestBody Map<String, Object> body) {
String userId = AuthUtil.getUserId();
double rating = ((Number) body.get("rating")).doubleValue();
String text = (String) body.get("review_text");
LocalDate visitedAt = body.get("visited_at") != null
? LocalDate.parse((String) body.get("visited_at")) : null;
return reviewService.create(userId, restaurantId, rating, text, visitedAt);
}
@PutMapping("/reviews/{reviewId}")
public Map<String, Object> updateReview(
@PathVariable String reviewId,
@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("review_text");
LocalDate visitedAt = body.get("visited_at") != null
? LocalDate.parse((String) body.get("visited_at")) : null;
if (!reviewService.update(reviewId, userId, rating, text, visitedAt)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Review not found or not yours");
}
return Map.of("ok", true);
}
@DeleteMapping("/reviews/{reviewId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteReview(@PathVariable String reviewId) {
String userId = AuthUtil.getUserId();
if (!reviewService.delete(reviewId, userId)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Review not found or not yours");
}
}
@GetMapping("/users/me/reviews")
public List<Review> myReviews(
@RequestParam(defaultValue = "20") int limit,
@RequestParam(defaultValue = "0") int offset) {
return reviewService.findByUser(AuthUtil.getUserId(), limit, offset);
}
// Favorites
@GetMapping("/restaurants/{restaurantId}/favorite")
public Map<String, Object> favoriteStatus(@PathVariable String restaurantId) {
return Map.of("favorited", reviewService.isFavorited(AuthUtil.getUserId(), restaurantId));
}
@PostMapping("/restaurants/{restaurantId}/favorite")
public Map<String, Object> toggleFavorite(@PathVariable String restaurantId) {
boolean result = reviewService.toggleFavorite(AuthUtil.getUserId(), restaurantId);
return Map.of("favorited", result);
}
@GetMapping("/users/me/favorites")
public List<Restaurant> myFavorites() {
return reviewService.getUserFavorites(AuthUtil.getUserId());
}
}

View File

@@ -0,0 +1,27 @@
package com.tasteby.controller;
import com.tasteby.domain.Restaurant;
import com.tasteby.service.SearchService;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/search")
public class SearchController {
private final SearchService searchService;
public SearchController(SearchService searchService) {
this.searchService = searchService;
}
@GetMapping
public List<Restaurant> search(
@RequestParam String q,
@RequestParam(defaultValue = "keyword") String mode,
@RequestParam(defaultValue = "20") int limit) {
if (limit > 100) limit = 100;
return searchService.search(q, mode, limit);
}
}

View File

@@ -0,0 +1,29 @@
package com.tasteby.controller;
import com.tasteby.domain.SiteVisitStats;
import com.tasteby.service.StatsService;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/stats")
public class StatsController {
private final StatsService statsService;
public StatsController(StatsService statsService) {
this.statsService = statsService;
}
@PostMapping("/visit")
public Map<String, Object> recordVisit() {
statsService.recordVisit();
return Map.of("ok", true);
}
@GetMapping("/visits")
public SiteVisitStats getVisits() {
return statsService.getVisits();
}
}

View File

@@ -0,0 +1,243 @@
package com.tasteby.controller;
import com.tasteby.domain.VideoDetail;
import com.tasteby.domain.VideoSummary;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.*;
import com.tasteby.util.JsonUtil;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/videos")
public class VideoController {
private final VideoService videoService;
private final CacheService cache;
private final YouTubeService youTubeService;
private final PipelineService pipelineService;
private final ExtractorService extractorService;
private final RestaurantService restaurantService;
private final GeocodingService geocodingService;
public VideoController(VideoService videoService, CacheService cache,
YouTubeService youTubeService, PipelineService pipelineService,
ExtractorService extractorService, RestaurantService restaurantService,
GeocodingService geocodingService) {
this.videoService = videoService;
this.cache = cache;
this.youTubeService = youTubeService;
this.pipelineService = pipelineService;
this.extractorService = extractorService;
this.restaurantService = restaurantService;
this.geocodingService = geocodingService;
}
@GetMapping
public List<VideoSummary> list(@RequestParam(required = false) String status) {
return videoService.findAll(status);
}
@GetMapping("/{id}")
public VideoDetail detail(@PathVariable String id) {
var video = videoService.findDetail(id);
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
return video;
}
@PutMapping("/{id}")
public Map<String, Object> updateTitle(@PathVariable String id, @RequestBody Map<String, String> body) {
AuthUtil.requireAdmin();
String title = body.get("title");
if (title == null || title.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "title is required");
}
videoService.updateTitle(id, title);
cache.flush();
return Map.of("ok", true);
}
@PostMapping("/{id}/skip")
public Map<String, Object> skip(@PathVariable String id) {
AuthUtil.requireAdmin();
videoService.updateStatus(id, "skip");
cache.flush();
return Map.of("ok", true);
}
@DeleteMapping("/{id}")
public Map<String, Object> delete(@PathVariable String id) {
AuthUtil.requireAdmin();
videoService.delete(id);
cache.flush();
return Map.of("ok", true);
}
@DeleteMapping("/{videoId}/restaurants/{restaurantId}")
public Map<String, Object> deleteVideoRestaurant(
@PathVariable String videoId, @PathVariable String restaurantId) {
AuthUtil.requireAdmin();
videoService.deleteVideoRestaurant(videoId, restaurantId);
cache.flush();
return Map.of("ok", true);
}
@PostMapping("/{id}/fetch-transcript")
public Map<String, Object> fetchTranscript(@PathVariable String id,
@RequestParam(defaultValue = "auto") String mode) {
AuthUtil.requireAdmin();
var video = videoService.findDetail(id);
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
var result = youTubeService.getTranscript(video.getVideoId(), mode);
if (result == null || result.text() == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No transcript available");
}
videoService.updateTranscript(id, result.text());
return Map.of("ok", true, "length", result.text().length(), "source", result.source());
}
@GetMapping("/extract/prompt")
public Map<String, Object> getExtractPrompt() {
return Map.of("prompt", extractorService.getPrompt());
}
@PostMapping("/{id}/extract")
public Map<String, Object> extract(@PathVariable String id,
@RequestBody(required = false) Map<String, String> body) {
AuthUtil.requireAdmin();
var video = videoService.findDetail(id);
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
if (video.getTranscriptText() == null || video.getTranscriptText().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No transcript");
}
String customPrompt = body != null ? body.get("prompt") : null;
var videoMap = Map.<String, Object>of("id", id, "video_id", video.getVideoId(), "title", video.getTitle());
int count = pipelineService.processExtract(videoMap, video.getTranscriptText(), customPrompt);
if (count > 0) cache.flush();
return Map.of("ok", true, "restaurants_extracted", count);
}
@GetMapping("/bulk-extract/pending")
public Map<String, Object> bulkExtractPending() {
var videos = videoService.findVideosForBulkExtract();
var summary = videos.stream().map(v -> Map.of("id", v.get("id"), "title", v.get("title"))).toList();
return Map.of("count", videos.size(), "videos", summary);
}
@GetMapping("/bulk-transcript/pending")
public Map<String, Object> bulkTranscriptPending() {
var videos = videoService.findVideosWithoutTranscript();
var summary = videos.stream().map(v -> Map.of("id", v.get("id"), "title", v.get("title"))).toList();
return Map.of("count", videos.size(), "videos", summary);
}
@SuppressWarnings("unchecked")
@PostMapping("/{videoId}/restaurants/manual")
public Map<String, Object> addManualRestaurant(@PathVariable String videoId,
@RequestBody Map<String, Object> body) {
AuthUtil.requireAdmin();
String name = (String) body.get("name");
if (name == null || name.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "name is required");
}
// Geocode
var geo = geocodingService.geocodeRestaurant(name, (String) body.get("address"));
var data = new HashMap<String, Object>();
data.put("name", name);
data.put("address", geo != null ? geo.get("formatted_address") : body.get("address"));
data.put("region", body.get("region"));
data.put("latitude", geo != null ? geo.get("latitude") : null);
data.put("longitude", geo != null ? geo.get("longitude") : null);
data.put("cuisine_type", body.get("cuisine_type"));
data.put("price_range", body.get("price_range"));
data.put("google_place_id", geo != null ? geo.get("google_place_id") : null);
data.put("phone", geo != null ? geo.get("phone") : null);
data.put("website", geo != null ? geo.get("website") : null);
data.put("business_status", geo != null ? geo.get("business_status") : null);
data.put("rating", geo != null ? geo.get("rating") : null);
data.put("rating_count", geo != null ? geo.get("rating_count") : null);
String restId = restaurantService.upsert(data);
// Parse foods and guests
List<String> foods = null;
Object foodsRaw = body.get("foods_mentioned");
if (foodsRaw instanceof List<?>) {
foods = ((List<?>) foodsRaw).stream().map(Object::toString).toList();
} else if (foodsRaw instanceof String s && !s.isBlank()) {
foods = List.of(s.split("\\s*,\\s*"));
}
List<String> guests = null;
Object guestsRaw = body.get("guests");
if (guestsRaw instanceof List<?>) {
guests = ((List<?>) guestsRaw).stream().map(Object::toString).toList();
} else if (guestsRaw instanceof String s && !s.isBlank()) {
guests = List.of(s.split("\\s*,\\s*"));
}
String evaluation = body.get("evaluation") instanceof String s ? s : null;
restaurantService.linkVideoRestaurant(videoId, restId, foods, evaluation, guests);
cache.flush();
return Map.of("ok", true, "restaurant_id", restId);
}
@SuppressWarnings("unchecked")
@PutMapping("/{videoId}/restaurants/{restaurantId}")
public Map<String, Object> updateVideoRestaurant(@PathVariable String videoId,
@PathVariable String restaurantId,
@RequestBody Map<String, Object> body) {
AuthUtil.requireAdmin();
// Update link fields (foods_mentioned, evaluation, guests)
List<String> foods = null;
Object foodsRaw = body.get("foods_mentioned");
if (foodsRaw instanceof List<?>) {
foods = ((List<?>) foodsRaw).stream().map(Object::toString).toList();
} else if (foodsRaw instanceof String s && !s.isBlank()) {
foods = List.of(s.split("\\s*,\\s*"));
}
List<String> guests = null;
Object guestsRaw = body.get("guests");
if (guestsRaw instanceof List<?>) {
guests = ((List<?>) guestsRaw).stream().map(Object::toString).toList();
} else if (guestsRaw instanceof String s && !s.isBlank()) {
guests = List.of(s.split("\\s*,\\s*"));
}
// evaluation must be valid JSON for DB IS JSON constraint
String evaluationJson = null;
Object evalRaw = body.get("evaluation");
if (evalRaw instanceof String s && !s.isBlank()) {
evaluationJson = s.trim().startsWith("{") || s.trim().startsWith("\"") ? s : JsonUtil.toJson(s);
} else if (evalRaw instanceof Map<?, ?>) {
evaluationJson = JsonUtil.toJson(evalRaw);
}
String foodsJson = foods != null ? JsonUtil.toJson(foods) : null;
String guestsJson = guests != null ? JsonUtil.toJson(guests) : null;
videoService.updateVideoRestaurantFields(videoId, restaurantId, foodsJson, evaluationJson, guestsJson);
// Update restaurant fields if provided
var restFields = new HashMap<String, Object>();
for (var key : List.of("name", "address", "region", "cuisine_type", "price_range")) {
if (body.containsKey(key)) restFields.put(key, body.get(key));
}
if (!restFields.isEmpty()) {
restaurantService.update(restaurantId, restFields);
}
cache.flush();
return Map.of("ok", true);
}
}

View File

@@ -0,0 +1,385 @@
package com.tasteby.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.*;
import com.tasteby.util.CuisineTypes;
import com.tasteby.util.JsonUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* SSE streaming endpoints for bulk operations.
*/
@RestController
@RequestMapping("/api/videos")
public class VideoSseController {
private static final Logger log = LoggerFactory.getLogger(VideoSseController.class);
private final VideoService videoService;
private final RestaurantService restaurantService;
private final PipelineService pipelineService;
private final OciGenAiService genAi;
private final CacheService cache;
private final ObjectMapper mapper;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
public VideoSseController(VideoService videoService,
RestaurantService restaurantService,
PipelineService pipelineService,
OciGenAiService genAi,
CacheService cache,
ObjectMapper mapper) {
this.videoService = videoService;
this.restaurantService = restaurantService;
this.pipelineService = pipelineService;
this.genAi = genAi;
this.cache = cache;
this.mapper = mapper;
}
@PostMapping("/bulk-transcript")
public SseEmitter bulkTranscript() {
AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L); // 10 min timeout
executor.execute(() -> {
try {
// TODO: Implement when transcript extraction is available in Java
emit(emitter, Map.of("type", "start", "total", 0));
emit(emitter, Map.of("type", "complete", "total", 0, "success", 0));
emitter.complete();
} catch (Exception e) {
log.error("Bulk transcript error", e);
emitter.completeWithError(e);
}
});
return emitter;
}
@PostMapping("/bulk-extract")
public SseEmitter bulkExtract() {
AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L);
executor.execute(() -> {
try {
var rows = videoService.findVideosForBulkExtract();
int total = rows.size();
int totalRestaurants = 0;
emit(emitter, Map.of("type", "start", "total", total));
for (int i = 0; i < total; i++) {
var v = rows.get(i);
if (i > 0) {
long delay = (long) (3000 + Math.random() * 5000);
emit(emitter, Map.of("type", "wait", "index", i, "delay", delay / 1000.0));
Thread.sleep(delay);
}
emit(emitter, Map.of("type", "processing", "index", i, "title", v.get("title")));
try {
int count = pipelineService.processExtract(v, (String) v.get("transcript"), null);
totalRestaurants += count;
emit(emitter, Map.of("type", "done", "index", i, "title", v.get("title"), "restaurants", count));
} catch (Exception e) {
log.error("Bulk extract error for {}: {}", v.get("video_id"), e.getMessage());
emit(emitter, Map.of("type", "error", "index", i, "title", v.get("title"), "message", e.getMessage()));
}
}
if (totalRestaurants > 0) cache.flush();
emit(emitter, Map.of("type", "complete", "total", total, "total_restaurants", totalRestaurants));
emitter.complete();
} catch (Exception e) {
log.error("Bulk extract error", e);
emitter.completeWithError(e);
}
});
return emitter;
}
@PostMapping("/remap-cuisine")
@SuppressWarnings("unchecked")
public SseEmitter remapCuisine() {
AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L);
int BATCH = 20;
executor.execute(() -> {
try {
var rows = restaurantService.findForRemapCuisine();
rows = rows.stream().map(JsonUtil::lowerKeys).toList();
int total = rows.size();
emit(emitter, Map.of("type", "start", "total", total));
int updated = 0;
var allMissed = new ArrayList<Map<String, Object>>();
// Pass 1
for (int i = 0; i < total; i += BATCH) {
var batch = rows.subList(i, Math.min(i + BATCH, total));
emit(emitter, Map.of("type", "processing", "current", Math.min(i + BATCH, total), "total", total, "pass", 1));
try {
var result = applyRemapBatch(batch);
updated += result.updated;
allMissed.addAll(result.missed);
emit(emitter, Map.of("type", "batch_done", "current", Math.min(i + BATCH, total), "total", total, "updated", updated, "missed", allMissed.size()));
} catch (Exception e) {
allMissed.addAll(batch);
emit(emitter, Map.of("type", "error", "message", e.getMessage(), "current", i));
}
}
// Pass 2: retry missed (up to 3 attempts with smaller batches)
if (!allMissed.isEmpty()) {
emit(emitter, Map.of("type", "retry", "missed", allMissed.size()));
for (int attempt = 0; attempt < 3 && !allMissed.isEmpty(); attempt++) {
var retryList = new ArrayList<>(allMissed);
allMissed.clear();
for (int i = 0; i < retryList.size(); i += 5) {
var batch = retryList.subList(i, Math.min(i + 5, retryList.size()));
try {
var result = applyRemapBatch(batch);
updated += result.updated;
allMissed.addAll(result.missed);
} catch (Exception e) {
log.warn("Remap cuisine retry failed (attempt {}): {}", attempt + 1, e.getMessage());
allMissed.addAll(batch);
}
}
if (!allMissed.isEmpty()) {
emit(emitter, Map.of("type", "retry", "attempt", attempt + 2, "missed", allMissed.size()));
}
}
}
cache.flush();
emit(emitter, Map.of("type", "complete", "total", total, "updated", updated, "missed", allMissed.size()));
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
@PostMapping("/remap-foods")
@SuppressWarnings("unchecked")
public SseEmitter remapFoods() {
AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L);
int BATCH = 15;
executor.execute(() -> {
try {
var rows = restaurantService.findForRemapFoods();
rows = rows.stream().map(r -> {
var m = JsonUtil.lowerKeys(r);
// foods_mentioned is now TO_CHAR'd in SQL, parse as string
Object fm = m.get("foods_mentioned");
m.put("foods", JsonUtil.parseStringList(fm));
return m;
}).toList();
int total = rows.size();
emit(emitter, Map.of("type", "start", "total", total));
int updated = 0;
var allMissed = new ArrayList<Map<String, Object>>();
for (int i = 0; i < total; i += BATCH) {
var batch = rows.subList(i, Math.min(i + BATCH, total));
emit(emitter, Map.of("type", "processing", "current", Math.min(i + BATCH, total), "total", total));
try {
var result = applyFoodsBatch(batch);
updated += result.updated;
allMissed.addAll(result.missed);
emit(emitter, Map.of("type", "batch_done", "current", Math.min(i + BATCH, total), "total", total, "updated", updated));
} catch (Exception e) {
allMissed.addAll(batch);
log.warn("Remap foods batch error at {}: {}", i, e.getMessage());
emit(emitter, Map.of("type", "error", "message", e.getMessage(), "current", i));
}
}
// Retry missed (up to 3 attempts with smaller batches)
if (!allMissed.isEmpty()) {
emit(emitter, Map.of("type", "retry", "missed", allMissed.size()));
for (int attempt = 0; attempt < 3 && !allMissed.isEmpty(); attempt++) {
var retryList = new ArrayList<>(allMissed);
allMissed.clear();
for (int i = 0; i < retryList.size(); i += 5) {
var batch = retryList.subList(i, Math.min(i + 5, retryList.size()));
try {
var r = applyFoodsBatch(batch);
updated += r.updated;
allMissed.addAll(r.missed);
} catch (Exception e) {
log.warn("Remap foods retry failed (attempt {}): {}", attempt + 1, e.getMessage());
allMissed.addAll(batch);
}
}
if (!allMissed.isEmpty()) {
emit(emitter, Map.of("type", "retry", "attempt", attempt + 2, "missed", allMissed.size()));
}
}
}
cache.flush();
emit(emitter, Map.of("type", "complete", "total", total, "updated", updated, "missed", allMissed.size()));
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
@PostMapping("/rebuild-vectors")
public SseEmitter rebuildVectors() {
AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L);
executor.execute(() -> {
try {
emit(emitter, Map.of("type", "start"));
// TODO: Implement full vector rebuild using VectorService
emit(emitter, Map.of("type", "complete", "total", 0));
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
@PostMapping("/process")
public Map<String, Object> process(@RequestParam(defaultValue = "5") int limit) {
AuthUtil.requireAdmin();
int count = pipelineService.processPending(limit);
if (count > 0) cache.flush();
return Map.of("restaurants_extracted", count);
}
// --- Helpers ---
private record BatchResult(int updated, List<Map<String, Object>> missed) {}
@SuppressWarnings("unchecked")
private BatchResult applyRemapBatch(List<Map<String, Object>> batch) throws Exception {
var items = batch.stream().map(b -> Map.of(
"id", b.get("id"), "name", b.get("name"),
"current_cuisine_type", b.get("cuisine_type"),
"foods_mentioned", b.get("foods_mentioned")
)).toList();
String prompt = """
아래 식당들의 cuisine_type을 표준 분류로 매핑하세요.
표준 분류 목록 (반드시 이 중 하나를 선택):
%s
식당 목록:
%s
규칙:
- 모든 식당에 대해 빠짐없이 결과를 반환 (총 %d개 모두 반환해야 함)
- 반드시 위 표준 분류 목록의 값을 그대로 복사하여 사용 (오타 금지)
- JSON 배열만 반환, 설명 없음
- 형식: [{"id": "식당ID", "cuisine_type": "한식|국밥/해장국"}, ...]
JSON 배열:""".formatted(CuisineTypes.CUISINE_LIST_TEXT, mapper.writeValueAsString(items), items.size());
String raw = genAi.chat(prompt, 4096);
Object parsed = genAi.parseJson(raw);
List<Map<String, Object>> results = parsed instanceof List<?> ? (List<Map<String, Object>>) parsed : List.of();
Map<String, String> resultMap = new HashMap<>();
for (var item : results) {
String id = (String) item.get("id");
String type = (String) item.get("cuisine_type");
if (id != null && type != null) resultMap.put(id, type);
}
int updated = 0;
var missed = new ArrayList<Map<String, Object>>();
for (var b : batch) {
String id = (String) b.get("id");
String newType = resultMap.get(id);
if (newType == null || !CuisineTypes.isValid(newType)) {
missed.add(b);
continue;
}
restaurantService.updateCuisineType(id, newType);
updated++;
}
return new BatchResult(updated, missed);
}
@SuppressWarnings("unchecked")
private BatchResult applyFoodsBatch(List<Map<String, Object>> batch) throws Exception {
var items = batch.stream().map(b -> Map.of(
"id", b.get("id"), "name", b.get("name"),
"current_foods", b.get("foods"), "cuisine_type", b.get("cuisine_type")
)).toList();
String prompt = """
아래 식당들의 대표 메뉴 태그를 다시 만들어주세요.
규칙:
- 반드시 한글로 작성
- 각 식당당 최대 10개의 대표 메뉴/음식 태그
- 우선순위: 시그니처 메뉴 > 자주 언급된 메뉴 > 일반 메뉴
- 너무 일반적인 태그(밥, 반찬 등)는 제외
- 모든 식당에 대해 빠짐없이 결과 반환 (총 %d개)
- JSON 배열만 반환, 설명 없음
- 형식: [{"id": "식당ID", "foods": ["메뉴1", "메뉴2", ...]}]
식당 목록:
%s
JSON 배열:""".formatted(items.size(), mapper.writeValueAsString(items));
String raw = genAi.chat(prompt, 4096);
Object parsed = genAi.parseJson(raw);
List<Map<String, Object>> results = parsed instanceof List<?> ? (List<Map<String, Object>>) parsed : List.of();
Map<String, List<String>> resultMap = new HashMap<>();
for (var item : results) {
String id = (String) item.get("id");
Object foods = item.get("foods");
if (id != null && foods instanceof List<?> list) {
resultMap.put(id, list.stream().map(Object::toString).limit(10).toList());
}
}
int updated = 0;
var missed = new ArrayList<Map<String, Object>>();
for (var b : batch) {
String id = (String) b.get("id");
List<String> newFoods = resultMap.get(id);
if (newFoods == null) {
missed.add(b);
continue;
}
restaurantService.updateFoodsMentioned(id, mapper.writeValueAsString(newFoods));
updated++;
}
return new BatchResult(updated, missed);
}
private void emit(SseEmitter emitter, Map<String, Object> data) {
try {
emitter.send(SseEmitter.event().data(mapper.writeValueAsString(data)));
} catch (Exception e) {
log.debug("SSE emit failed: {}", e.getMessage());
}
}
}

View File

@@ -0,0 +1,19 @@
package com.tasteby.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Channel {
private String id;
private String channelId;
private String channelName;
private String titleFilter;
private int videoCount;
private String lastVideoAt;
}

View File

@@ -0,0 +1,24 @@
package com.tasteby.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DaemonConfig {
private int id;
private boolean scanEnabled;
private int scanIntervalMin;
private boolean processEnabled;
private int processIntervalMin;
private int processLimit;
private Date lastScanAt;
private Date lastProcessAt;
private Date updatedAt;
}

View File

@@ -0,0 +1,35 @@
package com.tasteby.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Restaurant {
private String id;
private String name;
private String address;
private String region;
private Double latitude;
private Double longitude;
private String cuisineType;
private String priceRange;
private String phone;
private String website;
private String googlePlaceId;
private String businessStatus;
private Double rating;
private Integer ratingCount;
private Date updatedAt;
// Transient enrichment fields
private List<String> channels;
private List<String> foodsMentioned;
}

View File

@@ -0,0 +1,24 @@
package com.tasteby.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Review {
private String id;
private String userId;
private String restaurantId;
private double rating;
private String reviewText;
private String visitedAt;
private String createdAt;
private String updatedAt;
private String userNickname;
private String userAvatarUrl;
private String restaurantName;
}

View File

@@ -0,0 +1,15 @@
package com.tasteby.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SiteVisitStats {
private int today;
private int total;
}

View File

@@ -0,0 +1,25 @@
package com.tasteby.domain;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserInfo {
private String id;
private String email;
private String nickname;
private String avatarUrl;
@JsonProperty("is_admin")
private boolean admin;
private String provider;
private String providerId;
private String createdAt;
private int favoriteCount;
private int reviewCount;
}

View File

@@ -0,0 +1,16 @@
package com.tasteby.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class VectorSearchResult {
private String restaurantId;
private String chunkText;
private double distance;
}

View File

@@ -0,0 +1,30 @@
package com.tasteby.domain;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class VideoDetail {
private String id;
private String videoId;
private String title;
private String url;
private String status;
private String publishedAt;
private String channelName;
private boolean hasTranscript;
private boolean hasLlm;
private int restaurantCount;
private int matchedCount;
@JsonProperty("transcript")
private String transcriptText;
private List<VideoRestaurantLink> restaurants;
}

View File

@@ -0,0 +1,33 @@
package com.tasteby.domain;
import com.fasterxml.jackson.annotation.JsonRawValue;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class VideoRestaurantLink {
private String restaurantId;
private String name;
private String address;
private String cuisineType;
private String priceRange;
private String region;
@JsonRawValue
private String foodsMentioned;
@JsonRawValue
private String evaluation;
@JsonRawValue
private String guests;
private String googlePlaceId;
private Double latitude;
private Double longitude;
public boolean isHasLocation() {
return latitude != null && longitude != null;
}
}

View File

@@ -0,0 +1,24 @@
package com.tasteby.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class VideoSummary {
private String id;
private String videoId;
private String title;
private String url;
private String status;
private String publishedAt;
private String channelName;
private boolean hasTranscript;
private boolean hasLlm;
private int restaurantCount;
private int matchedCount;
}

View File

@@ -0,0 +1,24 @@
package com.tasteby.mapper;
import com.tasteby.domain.Channel;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ChannelMapper {
List<Channel> findAllActive();
void insert(@Param("id") String id,
@Param("channelId") String channelId,
@Param("channelName") String channelName,
@Param("titleFilter") String titleFilter);
int deactivateByChannelId(@Param("channelId") String channelId);
int deactivateById(@Param("id") String id);
Channel findByChannelId(@Param("channelId") String channelId);
}

View File

@@ -0,0 +1,16 @@
package com.tasteby.mapper;
import com.tasteby.domain.DaemonConfig;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface DaemonConfigMapper {
DaemonConfig getConfig();
void updateConfig(DaemonConfig config);
void updateLastScan();
void updateLastProcess();
}

View File

@@ -0,0 +1,61 @@
package com.tasteby.mapper;
import com.tasteby.domain.Restaurant;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface RestaurantMapper {
List<Restaurant> findAll(@Param("limit") int limit,
@Param("offset") int offset,
@Param("cuisine") String cuisine,
@Param("region") String region,
@Param("channel") String channel);
Restaurant findById(@Param("id") String id);
List<Map<String, Object>> findVideoLinks(@Param("restaurantId") String restaurantId);
void insertRestaurant(Restaurant r);
void updateRestaurant(Restaurant r);
void updateFields(@Param("id") String id, @Param("fields") Map<String, Object> fields);
void deleteVectors(@Param("id") String id);
void deleteReviews(@Param("id") String id);
void deleteFavorites(@Param("id") String id);
void deleteVideoRestaurants(@Param("id") String id);
void deleteRestaurant(@Param("id") String id);
void linkVideoRestaurant(@Param("id") String id,
@Param("videoId") String videoId,
@Param("restaurantId") String restaurantId,
@Param("foods") String foods,
@Param("evaluation") String evaluation,
@Param("guests") String guests);
String findIdByPlaceId(@Param("placeId") String placeId);
String findIdByName(@Param("name") String name);
List<Map<String, Object>> findChannelsByRestaurantIds(@Param("ids") List<String> ids);
List<Map<String, Object>> findFoodsByRestaurantIds(@Param("ids") List<String> ids);
void updateCuisineType(@Param("id") String id, @Param("cuisineType") String cuisineType);
void updateFoodsMentioned(@Param("id") String id, @Param("foods") String foods);
List<Map<String, Object>> findForRemapCuisine();
List<Map<String, Object>> findForRemapFoods();
}

View File

@@ -0,0 +1,52 @@
package com.tasteby.mapper;
import com.tasteby.domain.Restaurant;
import com.tasteby.domain.Review;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface ReviewMapper {
void insertReview(@Param("id") String id,
@Param("userId") String userId,
@Param("restaurantId") String restaurantId,
@Param("rating") double rating,
@Param("reviewText") String reviewText,
@Param("visitedAt") String visitedAt);
int updateReview(@Param("id") String id,
@Param("userId") String userId,
@Param("rating") Double rating,
@Param("reviewText") String reviewText,
@Param("visitedAt") String visitedAt);
int deleteReview(@Param("id") String id, @Param("userId") String userId);
Review findById(@Param("id") String id);
List<Review> findByRestaurant(@Param("restaurantId") String restaurantId,
@Param("limit") int limit,
@Param("offset") int offset);
Map<String, Object> getAvgRating(@Param("restaurantId") String restaurantId);
List<Review> findByUser(@Param("userId") String userId,
@Param("limit") int limit,
@Param("offset") int offset);
int countFavorite(@Param("userId") String userId, @Param("restaurantId") String restaurantId);
void insertFavorite(@Param("id") String id,
@Param("userId") String userId,
@Param("restaurantId") String restaurantId);
int deleteFavorite(@Param("userId") String userId, @Param("restaurantId") String restaurantId);
String findFavoriteId(@Param("userId") String userId, @Param("restaurantId") String restaurantId);
List<Restaurant> getUserFavorites(@Param("userId") String userId);
}

View File

@@ -0,0 +1,16 @@
package com.tasteby.mapper;
import com.tasteby.domain.Restaurant;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface SearchMapper {
List<Restaurant> keywordSearch(@Param("query") String query, @Param("limit") int limit);
List<Map<String, Object>> findChannelsByRestaurantIds(@Param("ids") List<String> ids);
}

View File

@@ -0,0 +1,13 @@
package com.tasteby.mapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface StatsMapper {
void recordVisit();
int getTodayVisits();
int getTotalVisits();
}

View File

@@ -0,0 +1,24 @@
package com.tasteby.mapper;
import com.tasteby.domain.UserInfo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface UserMapper {
UserInfo findByProviderAndProviderId(@Param("provider") String provider,
@Param("providerId") String providerId);
void updateLastLogin(@Param("id") String id);
void insert(UserInfo user);
UserInfo findById(@Param("id") String id);
List<UserInfo> findAllWithCounts(@Param("limit") int limit, @Param("offset") int offset);
int countAll();
}

View File

@@ -0,0 +1,20 @@
package com.tasteby.mapper;
import com.tasteby.domain.VectorSearchResult;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface VectorMapper {
List<VectorSearchResult> searchSimilar(@Param("queryVec") String queryVec,
@Param("topK") int topK,
@Param("maxDistance") double maxDistance);
void insertVector(@Param("id") String id,
@Param("restaurantId") String restaurantId,
@Param("chunkText") String chunkText,
@Param("embedding") String embedding);
}

View File

@@ -0,0 +1,76 @@
package com.tasteby.mapper;
import com.tasteby.domain.VideoDetail;
import com.tasteby.domain.VideoRestaurantLink;
import com.tasteby.domain.VideoSummary;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface VideoMapper {
List<VideoSummary> findAll(@Param("status") String status);
VideoDetail findDetail(@Param("id") String id);
List<VideoRestaurantLink> findVideoRestaurants(@Param("videoId") String videoId);
void updateStatus(@Param("id") String id, @Param("status") String status);
void updateTitle(@Param("id") String id, @Param("title") String title);
void updateTranscript(@Param("id") String id, @Param("transcript") String transcript);
void deleteVectorsByVideoOnly(@Param("videoId") String videoId);
void deleteReviewsByVideoOnly(@Param("videoId") String videoId);
void deleteFavoritesByVideoOnly(@Param("videoId") String videoId);
void deleteRestaurantsByVideoOnly(@Param("videoId") String videoId);
void deleteVideoRestaurants(@Param("videoId") String videoId);
void deleteVideo(@Param("videoId") String videoId);
void deleteOneVideoRestaurant(@Param("videoId") String videoId, @Param("restaurantId") String restaurantId);
void cleanupOrphanVectors(@Param("restaurantId") String restaurantId);
void cleanupOrphanReviews(@Param("restaurantId") String restaurantId);
void cleanupOrphanFavorites(@Param("restaurantId") String restaurantId);
void cleanupOrphanRestaurant(@Param("restaurantId") String restaurantId);
void insertVideo(@Param("id") String id,
@Param("channelId") String channelId,
@Param("videoId") String videoId,
@Param("title") String title,
@Param("url") String url,
@Param("publishedAt") String publishedAt);
List<String> getExistingVideoIds(@Param("channelId") String channelId);
String getLatestVideoDate(@Param("channelId") String channelId);
List<Map<String, Object>> findPendingVideos(@Param("limit") int limit);
void updateVideoFields(@Param("id") String id,
@Param("status") String status,
@Param("transcript") String transcript,
@Param("llmResponse") String llmResponse);
List<Map<String, Object>> findVideosForBulkExtract();
List<Map<String, Object>> findVideosWithoutTranscript();
void updateVideoRestaurantFields(@Param("videoId") String videoId,
@Param("restaurantId") String restaurantId,
@Param("foodsJson") String foodsJson,
@Param("evaluation") String evaluation,
@Param("guestsJson") String guestsJson);
}

View File

@@ -0,0 +1,43 @@
package com.tasteby.security;
import io.jsonwebtoken.Claims;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.server.ResponseStatusException;
/**
* Utility to extract current user info from SecurityContext.
*/
public final class AuthUtil {
private AuthUtil() {}
public static Claims getCurrentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !(auth.getPrincipal() instanceof Claims)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
}
return (Claims) auth.getPrincipal();
}
public static Claims getCurrentUserOrNull() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !(auth.getPrincipal() instanceof Claims)) {
return null;
}
return (Claims) auth.getPrincipal();
}
public static Claims requireAdmin() {
Claims user = getCurrentUser();
if (!Boolean.TRUE.equals(user.get("is_admin", Boolean.class))) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "관리자 권한이 필요합니다");
}
return user;
}
public static String getUserId() {
return getCurrentUser().getSubject();
}
}

View File

@@ -0,0 +1,48 @@
package com.tasteby.security;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7).trim();
if (tokenProvider.isValid(token)) {
Claims claims = tokenProvider.parseToken(token);
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
if (Boolean.TRUE.equals(claims.get("is_admin", Boolean.class))) {
authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
}
var auth = new UsernamePasswordAuthenticationToken(claims, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,64 @@
package com.tasteby.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Map;
@Component
public class JwtTokenProvider {
private final SecretKey key;
private final int expirationDays;
public JwtTokenProvider(
@Value("${app.jwt.secret}") String secret,
@Value("${app.jwt.expiration-days}") int expirationDays) {
// Pad secret to at least 32 bytes for HS256
String padded = secret.length() < 32
? secret + "0".repeat(32 - secret.length())
: secret;
this.key = Keys.hmacShaKeyFor(padded.getBytes(StandardCharsets.UTF_8));
this.expirationDays = expirationDays;
}
public String createToken(Map<String, Object> userInfo) {
Instant now = Instant.now();
Instant exp = now.plus(expirationDays, ChronoUnit.DAYS);
return Jwts.builder()
.subject((String) userInfo.get("id"))
.claim("email", userInfo.get("email"))
.claim("nickname", userInfo.get("nickname"))
.claim("is_admin", userInfo.get("is_admin"))
.issuedAt(Date.from(now))
.expiration(Date.from(exp))
.signWith(key)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
public boolean isValid(String token) {
try {
parseToken(token);
return true;
} catch (Exception e) {
return false;
}
}
}

View File

@@ -0,0 +1,72 @@
package com.tasteby.service;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.gson.GsonFactory;
import com.tasteby.domain.UserInfo;
import com.tasteby.security.JwtTokenProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.Collections;
import java.util.Map;
@Service
public class AuthService {
private final UserService userService;
private final JwtTokenProvider jwtProvider;
private final GoogleIdTokenVerifier verifier;
public AuthService(UserService userService, JwtTokenProvider jwtProvider,
@Value("${app.google.client-id}") String googleClientId) {
this.userService = userService;
this.jwtProvider = jwtProvider;
this.verifier = new GoogleIdTokenVerifier.Builder(
new NetHttpTransport(), GsonFactory.getDefaultInstance())
.setAudience(Collections.singletonList(googleClientId))
.build();
}
public Map<String, Object> loginGoogle(String idTokenString) {
try {
GoogleIdToken idToken = verifier.verify(idTokenString);
if (idToken == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid Google token");
}
GoogleIdToken.Payload payload = idToken.getPayload();
UserInfo user = userService.findOrCreate(
"google",
payload.getSubject(),
payload.getEmail(),
(String) payload.get("name"),
(String) payload.get("picture"));
// Convert to Map for JWT
Map<String, Object> userMap = Map.of(
"id", user.getId(),
"email", user.getEmail() != null ? user.getEmail() : "",
"nickname", user.getNickname() != null ? user.getNickname() : "",
"is_admin", user.isAdmin()
);
String accessToken = jwtProvider.createToken(userMap);
return Map.of("access_token", accessToken, "user", user);
} catch (ResponseStatusException e) {
throw e;
} catch (Exception e) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid Google token: " + e.getMessage());
}
}
public UserInfo getCurrentUser(String userId) {
UserInfo user = userService.findById(userId);
if (user == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
}
return user;
}
}

View File

@@ -0,0 +1,88 @@
package com.tasteby.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.Set;
@Service
public class CacheService {
private static final Logger log = LoggerFactory.getLogger(CacheService.class);
private static final String PREFIX = "tasteby:";
private final StringRedisTemplate redis;
private final ObjectMapper mapper;
private final Duration ttl;
private boolean disabled = false;
public CacheService(StringRedisTemplate redis, ObjectMapper mapper,
@Value("${app.cache.ttl-seconds:600}") int ttlSeconds) {
this.redis = redis;
this.mapper = mapper;
this.ttl = Duration.ofSeconds(ttlSeconds);
try {
redis.getConnectionFactory().getConnection().ping();
log.info("Redis connected");
} catch (Exception e) {
log.warn("Redis unavailable ({}), caching disabled", e.getMessage());
disabled = true;
}
}
public String makeKey(String... parts) {
return PREFIX + String.join(":", parts);
}
public <T> T get(String key, Class<T> type) {
if (disabled) return null;
try {
String val = redis.opsForValue().get(key);
if (val != null) {
return mapper.readValue(val, type);
}
} catch (Exception e) {
log.debug("Cache get error: {}", e.getMessage());
}
return null;
}
public String getRaw(String key) {
if (disabled) return null;
try {
return redis.opsForValue().get(key);
} catch (Exception e) {
log.debug("Cache get error: {}", e.getMessage());
return null;
}
}
public void set(String key, Object value) {
if (disabled) return;
try {
String json = mapper.writeValueAsString(value);
redis.opsForValue().set(key, json, ttl);
} catch (JsonProcessingException e) {
log.debug("Cache set error: {}", e.getMessage());
}
}
public void flush() {
if (disabled) return;
try {
Set<String> keys = redis.keys(PREFIX + "*");
if (keys != null && !keys.isEmpty()) {
redis.delete(keys);
}
log.info("Cache flushed");
} catch (Exception e) {
log.debug("Cache flush error: {}", e.getMessage());
}
}
}

View File

@@ -0,0 +1,41 @@
package com.tasteby.service;
import com.tasteby.domain.Channel;
import com.tasteby.mapper.ChannelMapper;
import com.tasteby.util.IdGenerator;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ChannelService {
private final ChannelMapper mapper;
public ChannelService(ChannelMapper mapper) {
this.mapper = mapper;
}
public List<Channel> findAllActive() {
return mapper.findAllActive();
}
public String create(String channelId, String channelName, String titleFilter) {
String id = IdGenerator.newId();
mapper.insert(id, channelId, channelName, titleFilter);
return id;
}
public boolean deactivate(String channelId) {
// Try deactivate by channel_id first, then by DB id
int rows = mapper.deactivateByChannelId(channelId);
if (rows == 0) {
rows = mapper.deactivateById(channelId);
}
return rows > 0;
}
public Channel findByChannelId(String channelId) {
return mapper.findByChannelId(channelId);
}
}

View File

@@ -0,0 +1,51 @@
package com.tasteby.service;
import com.tasteby.domain.DaemonConfig;
import com.tasteby.mapper.DaemonConfigMapper;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
public class DaemonConfigService {
private final DaemonConfigMapper mapper;
public DaemonConfigService(DaemonConfigMapper mapper) {
this.mapper = mapper;
}
public DaemonConfig getConfig() {
return mapper.getConfig();
}
public void updateConfig(Map<String, Object> body) {
DaemonConfig current = mapper.getConfig();
if (current == null) return;
if (body.containsKey("scan_enabled")) {
current.setScanEnabled(Boolean.TRUE.equals(body.get("scan_enabled")));
}
if (body.containsKey("scan_interval_min")) {
current.setScanIntervalMin(((Number) body.get("scan_interval_min")).intValue());
}
if (body.containsKey("process_enabled")) {
current.setProcessEnabled(Boolean.TRUE.equals(body.get("process_enabled")));
}
if (body.containsKey("process_interval_min")) {
current.setProcessIntervalMin(((Number) body.get("process_interval_min")).intValue());
}
if (body.containsKey("process_limit")) {
current.setProcessLimit(((Number) body.get("process_limit")).intValue());
}
mapper.updateConfig(current);
}
public void updateLastScan() {
mapper.updateLastScan();
}
public void updateLastProcess() {
mapper.updateLastProcess();
}
}

View File

@@ -0,0 +1,79 @@
package com.tasteby.service;
import com.tasteby.domain.DaemonConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
/**
* Background daemon that periodically scans channels and processes pending videos.
*/
@Service
public class DaemonScheduler {
private static final Logger log = LoggerFactory.getLogger(DaemonScheduler.class);
private final DaemonConfigService daemonConfigService;
private final YouTubeService youTubeService;
private final PipelineService pipelineService;
private final CacheService cacheService;
public DaemonScheduler(DaemonConfigService daemonConfigService,
YouTubeService youTubeService,
PipelineService pipelineService,
CacheService cacheService) {
this.daemonConfigService = daemonConfigService;
this.youTubeService = youTubeService;
this.pipelineService = pipelineService;
this.cacheService = cacheService;
}
@Scheduled(fixedDelay = 30_000) // Check every 30 seconds
public void run() {
try {
var config = getConfig();
if (config == null) return;
if (config.isScanEnabled()) {
Instant lastScan = config.getLastScanAt() != null ? config.getLastScanAt().toInstant() : null;
if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.getScanIntervalMin(), ChronoUnit.MINUTES))) {
log.info("Running scheduled channel scan...");
int newVideos = youTubeService.scanAllChannels();
daemonConfigService.updateLastScan();
if (newVideos > 0) {
cacheService.flush();
log.info("Scan completed: {} new videos", newVideos);
}
}
}
if (config.isProcessEnabled()) {
Instant lastProcess = config.getLastProcessAt() != null ? config.getLastProcessAt().toInstant() : null;
if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.getProcessIntervalMin(), ChronoUnit.MINUTES))) {
log.info("Running scheduled video processing (limit={})...", config.getProcessLimit());
int restaurants = pipelineService.processPending(config.getProcessLimit());
daemonConfigService.updateLastProcess();
if (restaurants > 0) {
cacheService.flush();
log.info("Processing completed: {} restaurants extracted", restaurants);
}
}
}
} catch (Exception e) {
log.error("Daemon scheduler error: {}", e.getMessage());
}
}
private DaemonConfig getConfig() {
try {
return daemonConfigService.getConfig();
} catch (Exception e) {
log.debug("Cannot read daemon config: {}", e.getMessage());
return null;
}
}
}

View File

@@ -0,0 +1,88 @@
package com.tasteby.service;
import com.tasteby.util.CuisineTypes;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@Service
public class ExtractorService {
private static final Logger log = LoggerFactory.getLogger(ExtractorService.class);
private final OciGenAiService genAi;
private static final String EXTRACT_PROMPT = """
다음은 유튜브 먹방/맛집 영상의 자막입니다.
이 영상에서 언급된 모든 식당 정보를 추출하세요.
규칙:
- 식당이 없으면 빈 배열 [] 반환
- 각 식당에 대해 아래 필드를 JSON 배열로 반환
- 확실하지 않은 정보는 null
- 추가 설명 없이 JSON만 반환
- 무조건 한글로 만들어주세요
필드:
- name: 식당 이름 (string, 필수)
- address: 주소 또는 위치 힌트 (string | null)
- region: 지역을 "나라|시/도|구/군/시" 파이프(|) 구분 형식으로 작성 (string | null)
- 한국 예시: "한국|서울|강남구", "한국|부산|해운대구", "한국|제주", "한국|강원|강릉시"
- 해외 예시: "일본|도쿄", "일본|오사카", "싱가포르", "미국|뉴욕", "태국|방콕"
- 나라는 한글로, 해외 도시도 한글로 표기
- cuisine_type: 아래 목록에서 가장 적합한 것을 선택 (string, 필수). 반드시 아래 목록 중 하나를 사용:
%s
- price_range: 가격대 (예: 1만원대, 2-3만원) (string | null)
- foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성)
- evaluation: 평가 내용 (string | null)
- guests: 함께한 게스트 (string[])
영상 제목: {title}
자막:
{transcript}
JSON 배열:""".formatted(CuisineTypes.CUISINE_LIST_TEXT);
public ExtractorService(OciGenAiService genAi) {
this.genAi = genAi;
}
public String getPrompt() {
return EXTRACT_PROMPT;
}
public record ExtractionResult(List<Map<String, Object>> restaurants, String rawResponse) {}
/**
* Extract restaurant info from a video transcript using LLM.
*/
@SuppressWarnings("unchecked")
public ExtractionResult extractRestaurants(String title, String transcript, String customPrompt) {
// Truncate very long transcripts
if (transcript.length() > 8000) {
transcript = transcript.substring(0, 7000) + "\n...(중략)...\n" + transcript.substring(transcript.length() - 1000);
}
String template = customPrompt != null ? customPrompt : EXTRACT_PROMPT;
String prompt = template.replace("{title}", title).replace("{transcript}", transcript);
try {
String raw = genAi.chat(prompt, 8192);
Object result = genAi.parseJson(raw);
if (result instanceof List<?> list) {
return new ExtractionResult((List<Map<String, Object>>) list, raw);
}
if (result instanceof Map<?, ?> map) {
return new ExtractionResult(List.of((Map<String, Object>) map), raw);
}
return new ExtractionResult(Collections.emptyList(), raw);
} catch (Exception e) {
log.error("Restaurant extraction failed: {}", e.getMessage());
return new ExtractionResult(Collections.emptyList(), "");
}
}
}

View File

@@ -0,0 +1,165 @@
package com.tasteby.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
@Service
public class GeocodingService {
private static final Logger log = LoggerFactory.getLogger(GeocodingService.class);
private final WebClient webClient;
private final ObjectMapper mapper;
private final String apiKey;
public GeocodingService(ObjectMapper mapper,
@Value("${app.google.maps-api-key}") String apiKey) {
this.webClient = WebClient.builder()
.baseUrl("https://maps.googleapis.com/maps/api")
.build();
this.mapper = mapper;
this.apiKey = apiKey;
}
/**
* Look up restaurant coordinates via Google Maps.
* Tries Places Text Search first, falls back to Geocoding API.
*/
public Map<String, Object> geocodeRestaurant(String name, String address) {
String query = name;
if (address != null && !address.isBlank()) {
query += " " + address;
}
// Try Places Text Search
Map<String, Object> result = placesTextSearch(query);
if (result != null) return result;
// Fallback: Geocoding
return geocode(query);
}
private Map<String, Object> placesTextSearch(String query) {
try {
String response = webClient.get()
.uri(uriBuilder -> uriBuilder.path("/place/textsearch/json")
.queryParam("query", query)
.queryParam("key", apiKey)
.queryParam("language", "ko")
.queryParam("type", "restaurant")
.build())
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofSeconds(10));
JsonNode data = mapper.readTree(response);
if (!"OK".equals(data.path("status").asText()) || !data.path("results").has(0)) {
return null;
}
JsonNode place = data.path("results").get(0);
JsonNode loc = place.path("geometry").path("location");
var result = new HashMap<String, Object>();
result.put("latitude", loc.path("lat").asDouble());
result.put("longitude", loc.path("lng").asDouble());
result.put("formatted_address", place.path("formatted_address").asText(""));
result.put("google_place_id", place.path("place_id").asText(""));
if (!place.path("business_status").isMissingNode()) {
result.put("business_status", place.path("business_status").asText());
}
if (!place.path("rating").isMissingNode()) {
result.put("rating", place.path("rating").asDouble());
}
if (!place.path("user_ratings_total").isMissingNode()) {
result.put("rating_count", place.path("user_ratings_total").asInt());
}
// Fetch phone/website from Place Details
String placeId = place.path("place_id").asText(null);
if (placeId != null) {
var details = placeDetails(placeId);
if (details != null) {
result.putAll(details);
}
}
return result;
} catch (Exception e) {
log.warn("Places text search failed for '{}': {}", query, e.getMessage());
return null;
}
}
private Map<String, Object> placeDetails(String placeId) {
try {
String response = webClient.get()
.uri(uriBuilder -> uriBuilder.path("/place/details/json")
.queryParam("place_id", placeId)
.queryParam("key", apiKey)
.queryParam("language", "ko")
.queryParam("fields", "formatted_phone_number,website")
.build())
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofSeconds(10));
JsonNode data = mapper.readTree(response);
if (!"OK".equals(data.path("status").asText())) return null;
JsonNode res = data.path("result");
var details = new HashMap<String, Object>();
if (!res.path("formatted_phone_number").isMissingNode()) {
details.put("phone", res.path("formatted_phone_number").asText());
}
if (!res.path("website").isMissingNode()) {
details.put("website", res.path("website").asText());
}
return details;
} catch (Exception e) {
log.warn("Place details failed for '{}': {}", placeId, e.getMessage());
return null;
}
}
private Map<String, Object> geocode(String query) {
try {
String response = webClient.get()
.uri(uriBuilder -> uriBuilder.path("/geocode/json")
.queryParam("address", query)
.queryParam("key", apiKey)
.queryParam("language", "ko")
.build())
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofSeconds(10));
JsonNode data = mapper.readTree(response);
if (!"OK".equals(data.path("status").asText()) || !data.path("results").has(0)) {
return null;
}
JsonNode result = data.path("results").get(0);
JsonNode loc = result.path("geometry").path("location");
var map = new HashMap<String, Object>();
map.put("latitude", loc.path("lat").asDouble());
map.put("longitude", loc.path("lng").asDouble());
map.put("formatted_address", result.path("formatted_address").asText(""));
map.put("google_place_id", "");
return map;
} catch (Exception e) {
log.warn("Geocoding failed for '{}': {}", query, e.getMessage());
return null;
}
}
}

View File

@@ -0,0 +1,177 @@
package com.tasteby.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.oracle.bmc.ConfigFileReader;
import com.oracle.bmc.auth.ConfigFileAuthenticationDetailsProvider;
import com.oracle.bmc.generativeaiinference.GenerativeAiInferenceClient;
import com.oracle.bmc.generativeaiinference.model.*;
import com.oracle.bmc.generativeaiinference.requests.ChatRequest;
import com.oracle.bmc.generativeaiinference.requests.EmbedTextRequest;
import com.oracle.bmc.generativeaiinference.responses.ChatResponse;
import com.oracle.bmc.generativeaiinference.responses.EmbedTextResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
public class OciGenAiService {
private static final Logger log = LoggerFactory.getLogger(OciGenAiService.class);
private static final int EMBED_BATCH_SIZE = 96;
@Value("${app.oci.compartment-id}")
private String compartmentId;
@Value("${app.oci.chat-endpoint}")
private String chatEndpoint;
@Value("${app.oci.embed-endpoint}")
private String embedEndpoint;
@Value("${app.oci.chat-model-id}")
private String chatModelId;
@Value("${app.oci.embed-model-id}")
private String embedModelId;
private final ObjectMapper mapper;
private ConfigFileAuthenticationDetailsProvider authProvider;
public OciGenAiService(ObjectMapper mapper) {
this.mapper = mapper;
}
@PostConstruct
public void init() {
try {
ConfigFileReader.ConfigFile configFile = ConfigFileReader.parseDefault();
authProvider = new ConfigFileAuthenticationDetailsProvider(configFile);
log.info("OCI GenAI auth configured");
} catch (Exception e) {
log.warn("OCI config not found, GenAI features disabled: {}", e.getMessage());
}
}
/**
* Call OCI GenAI LLM (Chat).
*/
public String chat(String prompt, int maxTokens) {
if (authProvider == 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 userMessage = UserMessage.builder().content(List.of(textContent)).build();
var chatRequest = GenericChatRequest.builder()
.messages(List.of(userMessage))
.maxTokens(maxTokens)
.temperature(0.0)
.build();
var chatDetails = ChatDetails.builder()
.compartmentId(compartmentId)
.servingMode(OnDemandServingMode.builder().modelId(chatModelId).build())
.chatRequest(chatRequest)
.build();
ChatResponse response = client.chat(
ChatRequest.builder().chatDetails(chatDetails).build());
var chatResult = (GenericChatResponse) response.getChatResult().getChatResponse();
var choice = chatResult.getChoices().get(0);
var content = ((TextContent) choice.getMessage().getContent().get(0)).getText();
return content.trim();
}
}
/**
* Generate embeddings for a list of texts.
*/
public List<List<Double>> embedTexts(List<String> texts) {
if (authProvider == null) throw new IllegalStateException("OCI GenAI not configured");
List<List<Double>> allEmbeddings = new ArrayList<>();
for (int i = 0; i < texts.size(); i += EMBED_BATCH_SIZE) {
List<String> batch = texts.subList(i, Math.min(i + EMBED_BATCH_SIZE, texts.size()));
allEmbeddings.addAll(embedBatch(batch));
}
return allEmbeddings;
}
private List<List<Double>> embedBatch(List<String> texts) {
try (var client = GenerativeAiInferenceClient.builder()
.endpoint(embedEndpoint)
.build(authProvider)) {
var embedDetails = EmbedTextDetails.builder()
.inputs(texts)
.servingMode(OnDemandServingMode.builder().modelId(embedModelId).build())
.compartmentId(compartmentId)
.inputType(EmbedTextDetails.InputType.SearchDocument)
.build();
EmbedTextResponse response = client.embedText(
EmbedTextRequest.builder().embedTextDetails(embedDetails).build());
return response.getEmbedTextResult().getEmbeddings()
.stream()
.map(emb -> emb.stream().map(Number::doubleValue).toList())
.toList();
}
}
/**
* Parse LLM response as JSON (handles markdown code blocks, truncated arrays, etc.)
*/
public Object parseJson(String raw) {
// Strip markdown code blocks
raw = raw.replaceAll("(?m)^```(?:json)?\\s*|\\s*```$", "").trim();
// Remove trailing commas
raw = raw.replaceAll(",\\s*([}\\]])", "$1");
try {
return mapper.readValue(raw, Object.class);
} catch (Exception ignored) {}
// Try to recover truncated array
if (raw.trim().startsWith("[")) {
List<Object> items = new ArrayList<>();
int idx = raw.indexOf('[') + 1;
while (idx < raw.length()) {
while (idx < raw.length() && " \t\n\r,".indexOf(raw.charAt(idx)) >= 0) idx++;
if (idx >= raw.length() || raw.charAt(idx) == ']') break;
// Try to parse next object
boolean found = false;
for (int end = idx + 1; end <= raw.length(); end++) {
try {
Object obj = mapper.readValue(raw.substring(idx, end), Object.class);
items.add(obj);
idx = end;
found = true;
break;
} catch (Exception ignored2) {}
}
if (!found) break;
}
if (!items.isEmpty()) {
log.info("Recovered {} items from truncated JSON", items.size());
return items;
}
}
throw new RuntimeException("JSON parse failed: " + raw.substring(0, Math.min(80, raw.length())));
}
}

View File

@@ -0,0 +1,180 @@
package com.tasteby.service;
import com.tasteby.util.JsonUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Data pipeline: process pending videos end-to-end.
* 1. Fetch transcript
* 2. Extract restaurant info via LLM
* 3. Geocode each restaurant
* 4. Save to DB + generate vector embeddings
*/
@Service
public class PipelineService {
private static final Logger log = LoggerFactory.getLogger(PipelineService.class);
private final YouTubeService youTubeService;
private final ExtractorService extractorService;
private final GeocodingService geocodingService;
private final RestaurantService restaurantService;
private final VideoService videoService;
private final VectorService vectorService;
private final CacheService cacheService;
public PipelineService(YouTubeService youTubeService,
ExtractorService extractorService,
GeocodingService geocodingService,
RestaurantService restaurantService,
VideoService videoService,
VectorService vectorService,
CacheService cacheService) {
this.youTubeService = youTubeService;
this.extractorService = extractorService;
this.geocodingService = geocodingService;
this.restaurantService = restaurantService;
this.videoService = videoService;
this.vectorService = vectorService;
this.cacheService = cacheService;
}
/**
* Process a single pending video. Returns number of restaurants found.
*/
public int processVideo(Map<String, Object> video) {
String videoDbId = (String) video.get("id");
String videoId = (String) video.get("video_id");
String title = (String) video.get("title");
log.info("Processing video: {} ({})", title, videoId);
updateVideoStatus(videoDbId, "processing", null, null);
try {
// 1. Transcript
var transcript = youTubeService.getTranscript(videoId, "auto");
if (transcript == null || transcript.text() == null) {
log.warn("No transcript for {}, marking done", videoId);
updateVideoStatus(videoDbId, "done", null, null);
return 0;
}
updateVideoStatus(videoDbId, "processing", transcript.text(), null);
// 2. LLM extraction + geocode + save
return processExtract(video, transcript.text(), null);
} catch (Exception e) {
log.error("Pipeline error for {}: {}", videoId, e.getMessage(), e);
updateVideoStatus(videoDbId, "error", null, null);
return 0;
}
}
/**
* Run LLM extraction + geocode + save on existing transcript.
*/
@SuppressWarnings("unchecked")
public int processExtract(Map<String, Object> video, String transcript, String customPrompt) {
String videoDbId = (String) video.get("id");
String title = (String) video.get("title");
var result = extractorService.extractRestaurants(title, transcript, customPrompt);
if (result.restaurants().isEmpty()) {
updateVideoStatus(videoDbId, "done", null, result.rawResponse());
return 0;
}
int count = 0;
for (var restData : result.restaurants()) {
String name = (String) restData.get("name");
if (name == null) continue;
// Geocode
var geo = geocodingService.geocodeRestaurant(
name, (String) restData.get("address"));
// Build upsert data
var data = new HashMap<String, Object>();
data.put("name", name);
data.put("address", geo != null ? geo.get("formatted_address") : restData.get("address"));
data.put("region", restData.get("region"));
data.put("latitude", geo != null ? geo.get("latitude") : null);
data.put("longitude", geo != null ? geo.get("longitude") : null);
data.put("cuisine_type", restData.get("cuisine_type"));
data.put("price_range", restData.get("price_range"));
data.put("google_place_id", geo != null ? geo.get("google_place_id") : null);
data.put("phone", geo != null ? geo.get("phone") : null);
data.put("website", geo != null ? geo.get("website") : null);
data.put("business_status", geo != null ? geo.get("business_status") : null);
data.put("rating", geo != null ? geo.get("rating") : null);
data.put("rating_count", geo != null ? geo.get("rating_count") : null);
String restId = restaurantService.upsert(data);
// Link video <-> restaurant
var foods = restData.get("foods_mentioned");
var evaluationRaw = restData.get("evaluation");
var guests = restData.get("guests");
// evaluation must be stored as valid JSON (DB has IS JSON check constraint)
// Store as JSON string literal: "평가 내용" (valid JSON)
String evaluationJson = null;
if (evaluationRaw instanceof Map<?, ?>) {
evaluationJson = JsonUtil.toJson(evaluationRaw);
} else if (evaluationRaw instanceof String s && !s.isBlank()) {
evaluationJson = JsonUtil.toJson(s);
}
restaurantService.linkVideoRestaurant(
videoDbId, restId,
foods instanceof List<?> ? (List<String>) foods : null,
evaluationJson,
guests instanceof List<?> ? (List<String>) guests : null
);
// Vector embeddings
var chunks = VectorService.buildChunks(name, restData, title);
if (!chunks.isEmpty()) {
try {
vectorService.saveRestaurantVectors(restId, chunks);
} catch (Exception e) {
log.warn("Vector save failed for {}: {}", name, e.getMessage());
}
}
count++;
log.info("Saved restaurant: {} (geocoded={})", name, geo != null);
}
updateVideoStatus(videoDbId, "done", null, result.rawResponse());
log.info("Video {} done: {} restaurants", video.get("video_id"), count);
return count;
}
/**
* Process up to `limit` pending videos.
*/
public int processPending(int limit) {
var videos = videoService.findPendingVideos(limit);
if (videos.isEmpty()) {
log.info("No pending videos");
return 0;
}
int total = 0;
for (var v : videos) {
total += processVideo(v);
}
if (total > 0) cacheService.flush();
return total;
}
private void updateVideoStatus(String videoDbId, String status, String transcript, String llmRaw) {
videoService.updateVideoFields(videoDbId, status, transcript, llmRaw);
}
}

View File

@@ -0,0 +1,162 @@
package com.tasteby.service;
import com.tasteby.domain.Restaurant;
import com.tasteby.mapper.RestaurantMapper;
import com.tasteby.util.IdGenerator;
import com.tasteby.util.JsonUtil;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class RestaurantService {
private final RestaurantMapper mapper;
public RestaurantService(RestaurantMapper mapper) {
this.mapper = mapper;
}
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel) {
List<Restaurant> restaurants = mapper.findAll(limit, offset, cuisine, region, channel);
enrichRestaurants(restaurants);
return restaurants;
}
public Restaurant findById(String id) {
Restaurant restaurant = mapper.findById(id);
if (restaurant == null) return null;
enrichRestaurants(List.of(restaurant));
return restaurant;
}
public List<Map<String, Object>> findVideoLinks(String restaurantId) {
var rows = mapper.findVideoLinks(restaurantId);
return rows.stream().map(row -> {
var m = JsonUtil.lowerKeys(row);
m.put("foods_mentioned", JsonUtil.parseStringList(m.get("foods_mentioned")));
m.put("evaluation", JsonUtil.parseMap(m.get("evaluation")));
m.put("guests", JsonUtil.parseStringList(m.get("guests")));
return m;
}).toList();
}
public void update(String id, Map<String, Object> fields) {
mapper.updateFields(id, fields);
}
@Transactional
public void delete(String id) {
mapper.deleteVectors(id);
mapper.deleteReviews(id);
mapper.deleteFavorites(id);
mapper.deleteVideoRestaurants(id);
mapper.deleteRestaurant(id);
}
public String upsert(Map<String, Object> data) {
String placeId = (String) data.get("google_place_id");
String existingId = null;
if (placeId != null && !placeId.isBlank()) {
existingId = mapper.findIdByPlaceId(placeId);
}
if (existingId == null) {
existingId = mapper.findIdByName((String) data.get("name"));
}
Restaurant r = Restaurant.builder()
.name(truncateBytes((String) data.get("name"), 200))
.address(truncateBytes((String) data.get("address"), 500))
.region((String) data.get("region"))
.latitude(data.get("latitude") instanceof Number n ? n.doubleValue() : null)
.longitude(data.get("longitude") instanceof Number n ? n.doubleValue() : null)
.cuisineType((String) data.get("cuisine_type"))
.priceRange((String) data.get("price_range"))
.googlePlaceId(placeId)
.phone((String) data.get("phone"))
.website((String) data.get("website"))
.businessStatus((String) data.get("business_status"))
.rating(data.get("rating") instanceof Number n ? n.doubleValue() : null)
.ratingCount(data.get("rating_count") instanceof Number n ? n.intValue() : null)
.build();
if (existingId != null) {
r.setId(existingId);
mapper.updateRestaurant(r);
return existingId;
} else {
String newId = IdGenerator.newId();
r.setId(newId);
mapper.insertRestaurant(r);
return newId;
}
}
public void linkVideoRestaurant(String videoId, String restaurantId, List<String> foods, String evaluation, List<String> guests) {
String id = IdGenerator.newId();
String foodsJson = foods != null ? JsonUtil.toJson(foods) : null;
String guestsJson = guests != null ? JsonUtil.toJson(guests) : null;
mapper.linkVideoRestaurant(id, videoId, restaurantId, foodsJson, evaluation, guestsJson);
}
public void updateCuisineType(String id, String cuisineType) {
mapper.updateCuisineType(id, cuisineType);
}
public void updateFoodsMentioned(String id, String foods) {
mapper.updateFoodsMentioned(id, foods);
}
public List<Map<String, Object>> findForRemapCuisine() {
return mapper.findForRemapCuisine();
}
public List<Map<String, Object>> findForRemapFoods() {
return mapper.findForRemapFoods();
}
private void enrichRestaurants(List<Restaurant> restaurants) {
if (restaurants.isEmpty()) return;
List<String> ids = restaurants.stream().map(Restaurant::getId).filter(Objects::nonNull).toList();
if (ids.isEmpty()) return;
// Channels
List<Map<String, Object>> channelRows = mapper.findChannelsByRestaurantIds(ids);
Map<String, List<String>> channelMap = new HashMap<>();
for (var row : channelRows) {
String rid = (String) row.getOrDefault("restaurant_id", row.get("RESTAURANT_ID"));
String ch = (String) row.getOrDefault("channel_name", row.get("CHANNEL_NAME"));
if (rid != null && ch != null) {
channelMap.computeIfAbsent(rid, k -> new ArrayList<>()).add(ch);
}
}
// Foods
List<Map<String, Object>> foodRows = mapper.findFoodsByRestaurantIds(ids);
Map<String, Set<String>> foodMap = new HashMap<>();
for (var row : foodRows) {
String rid = (String) row.getOrDefault("restaurant_id", row.get("RESTAURANT_ID"));
Object foods = row.getOrDefault("foods_mentioned", row.get("FOODS_MENTIONED"));
if (rid != null && foods != null) {
List<String> parsed = JsonUtil.parseStringList(foods);
foodMap.computeIfAbsent(rid, k -> new LinkedHashSet<>()).addAll(parsed);
}
}
for (var r : restaurants) {
r.setChannels(channelMap.getOrDefault(r.getId(), List.of()));
Set<String> foods = foodMap.get(r.getId());
r.setFoodsMentioned(foods != null ? new ArrayList<>(foods) : List.of());
}
}
private String truncateBytes(String s, int maxBytes) {
if (s == null) return null;
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
if (bytes.length <= maxBytes) return s;
return new String(bytes, 0, maxBytes, StandardCharsets.UTF_8);
}
}

View File

@@ -0,0 +1,72 @@
package com.tasteby.service;
import com.tasteby.domain.Restaurant;
import com.tasteby.domain.Review;
import com.tasteby.mapper.ReviewMapper;
import com.tasteby.util.IdGenerator;
import com.tasteby.util.JsonUtil;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.*;
@Service
public class ReviewService {
private final ReviewMapper mapper;
public ReviewService(ReviewMapper mapper) {
this.mapper = mapper;
}
public List<Review> findByRestaurant(String restaurantId, int limit, int offset) {
return mapper.findByRestaurant(restaurantId, limit, offset);
}
public Map<String, Object> getAvgRating(String restaurantId) {
Map<String, Object> result = mapper.getAvgRating(restaurantId);
return result != null ? JsonUtil.lowerKeys(result) : Map.of("avg_rating", 0.0, "review_count", 0);
}
@Transactional
public Review create(String userId, String restaurantId, double rating, String reviewText, LocalDate visitedAt) {
String id = IdGenerator.newId();
String visitedStr = visitedAt != null ? visitedAt.toString() : null;
mapper.insertReview(id, userId, restaurantId, rating, reviewText, visitedStr);
return mapper.findById(id);
}
public boolean update(String reviewId, String userId, Double rating, String reviewText, LocalDate visitedAt) {
String visitedStr = visitedAt != null ? visitedAt.toString() : null;
return mapper.updateReview(reviewId, userId, rating, reviewText, visitedStr) > 0;
}
public boolean delete(String reviewId, String userId) {
return mapper.deleteReview(reviewId, userId) > 0;
}
public List<Review> findByUser(String userId, int limit, int offset) {
return mapper.findByUser(userId, limit, offset);
}
public boolean isFavorited(String userId, String restaurantId) {
return mapper.countFavorite(userId, restaurantId) > 0;
}
@Transactional
public boolean toggleFavorite(String userId, String restaurantId) {
String existingId = mapper.findFavoriteId(userId, restaurantId);
if (existingId != null) {
mapper.deleteFavorite(userId, restaurantId);
return false;
} else {
mapper.insertFavorite(IdGenerator.newId(), userId, restaurantId);
return true;
}
}
public List<Restaurant> getUserFavorites(String userId) {
return mapper.getUserFavorites(userId);
}
}

View File

@@ -0,0 +1,111 @@
package com.tasteby.service;
import com.tasteby.domain.Restaurant;
import com.tasteby.mapper.SearchMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.*;
@Service
public class SearchService {
private static final Logger log = LoggerFactory.getLogger(SearchService.class);
private final SearchMapper searchMapper;
private final RestaurantService restaurantService;
private final VectorService vectorService;
private final CacheService cache;
public SearchService(SearchMapper searchMapper,
RestaurantService restaurantService,
VectorService vectorService,
CacheService cache) {
this.searchMapper = searchMapper;
this.restaurantService = restaurantService;
this.vectorService = vectorService;
this.cache = cache;
}
public List<Restaurant> search(String q, String mode, int limit) {
String key = cache.makeKey("search", "q=" + q, "m=" + mode, "l=" + limit);
String cached = cache.getRaw(key);
if (cached != null) {
try {
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
return mapper.readValue(cached, new com.fasterxml.jackson.core.type.TypeReference<List<Restaurant>>() {});
} catch (Exception ignored) {}
}
List<Restaurant> result;
switch (mode) {
case "semantic" -> result = semanticSearch(q, limit);
case "hybrid" -> {
var kw = keywordSearch(q, limit);
var sem = semanticSearch(q, limit);
Set<String> seen = new HashSet<>();
var merged = new ArrayList<Restaurant>();
for (var r : kw) { if (seen.add(r.getId())) merged.add(r); }
for (var r : sem) { if (seen.add(r.getId())) merged.add(r); }
result = merged.size() > limit ? merged.subList(0, limit) : merged;
}
default -> result = keywordSearch(q, limit);
}
cache.set(key, result);
return result;
}
private List<Restaurant> keywordSearch(String q, int limit) {
String pattern = "%" + q + "%";
List<Restaurant> results = searchMapper.keywordSearch(pattern, limit);
if (!results.isEmpty()) {
attachChannels(results);
}
return results;
}
private List<Restaurant> semanticSearch(String q, int limit) {
try {
var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), 0.57);
if (similar.isEmpty()) return List.of();
Set<String> seen = new LinkedHashSet<>();
for (var s : similar) {
seen.add((String) s.get("restaurant_id"));
}
List<Restaurant> results = new ArrayList<>();
for (String rid : seen) {
if (results.size() >= limit) break;
var r = restaurantService.findById(rid);
if (r != null && r.getLatitude() != null) {
results.add(r);
}
}
return results;
} catch (Exception e) {
log.warn("Semantic search failed, falling back to keyword: {}", e.getMessage());
return keywordSearch(q, limit);
}
}
private void attachChannels(List<Restaurant> restaurants) {
List<String> ids = restaurants.stream().map(Restaurant::getId).filter(Objects::nonNull).toList();
if (ids.isEmpty()) return;
var channelRows = searchMapper.findChannelsByRestaurantIds(ids);
Map<String, List<String>> chMap = new HashMap<>();
for (var row : channelRows) {
String rid = (String) row.getOrDefault("restaurant_id", row.get("RESTAURANT_ID"));
String ch = (String) row.getOrDefault("channel_name", row.get("CHANNEL_NAME"));
if (rid != null && ch != null) {
chMap.computeIfAbsent(rid, k -> new ArrayList<>()).add(ch);
}
}
for (var r : restaurants) {
r.setChannels(chMap.getOrDefault(r.getId(), List.of()));
}
}
}

View File

@@ -0,0 +1,26 @@
package com.tasteby.service;
import com.tasteby.domain.SiteVisitStats;
import com.tasteby.mapper.StatsMapper;
import org.springframework.stereotype.Service;
@Service
public class StatsService {
private final StatsMapper mapper;
public StatsService(StatsMapper mapper) {
this.mapper = mapper;
}
public void recordVisit() {
mapper.recordVisit();
}
public SiteVisitStats getVisits() {
return SiteVisitStats.builder()
.today(mapper.getTodayVisits())
.total(mapper.getTotalVisits())
.build();
}
}

View File

@@ -0,0 +1,50 @@
package com.tasteby.service;
import com.tasteby.domain.UserInfo;
import com.tasteby.mapper.UserMapper;
import com.tasteby.util.IdGenerator;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class UserService {
private final UserMapper mapper;
public UserService(UserMapper mapper) {
this.mapper = mapper;
}
@Transactional
public UserInfo findOrCreate(String provider, String providerId, String email, String nickname, String avatarUrl) {
UserInfo existing = mapper.findByProviderAndProviderId(provider, providerId);
if (existing != null) {
mapper.updateLastLogin(existing.getId());
return mapper.findById(existing.getId());
}
UserInfo user = UserInfo.builder()
.id(IdGenerator.newId())
.provider(provider)
.providerId(providerId)
.email(email)
.nickname(nickname)
.avatarUrl(avatarUrl)
.build();
mapper.insert(user);
return mapper.findById(user.getId());
}
public UserInfo findById(String userId) {
return mapper.findById(userId);
}
public List<UserInfo> findAllWithCounts(int limit, int offset) {
return mapper.findAllWithCounts(limit, offset);
}
public int countAll() {
return mapper.countAll();
}
}

View File

@@ -0,0 +1,106 @@
package com.tasteby.service;
import com.tasteby.util.JsonUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.*;
@Service
public class VectorService {
private static final Logger log = LoggerFactory.getLogger(VectorService.class);
private final NamedParameterJdbcTemplate jdbc;
private final OciGenAiService genAi;
public VectorService(NamedParameterJdbcTemplate jdbc, OciGenAiService genAi) {
this.jdbc = jdbc;
this.genAi = genAi;
}
/**
* Search similar restaurants by vector distance.
*/
public List<Map<String, Object>> searchSimilar(String query, int topK, double maxDistance) {
List<List<Double>> embeddings = genAi.embedTexts(List.of(query));
if (embeddings.isEmpty()) return List.of();
// Convert to float array for Oracle VECTOR type
float[] queryVec = new float[embeddings.getFirst().size()];
for (int i = 0; i < queryVec.length; i++) {
queryVec[i] = embeddings.getFirst().get(i).floatValue();
}
String sql = """
SELECT rv.restaurant_id, rv.chunk_text,
VECTOR_DISTANCE(rv.embedding, :qvec, COSINE) AS dist
FROM restaurant_vectors rv
WHERE VECTOR_DISTANCE(rv.embedding, :qvec2, COSINE) <= :max_dist
ORDER BY dist
FETCH FIRST :k ROWS ONLY
""";
var params = new MapSqlParameterSource();
params.addValue("qvec", queryVec);
params.addValue("qvec2", queryVec);
params.addValue("max_dist", maxDistance);
params.addValue("k", topK);
return jdbc.query(sql, params, (rs, rowNum) -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("restaurant_id", rs.getString("RESTAURANT_ID"));
m.put("chunk_text", JsonUtil.readClob(rs.getObject("CHUNK_TEXT")));
m.put("distance", rs.getDouble("DIST"));
return m;
});
}
/**
* Save vector embeddings for a restaurant.
*/
public void saveRestaurantVectors(String restaurantId, List<String> chunks) {
if (chunks.isEmpty()) return;
List<List<Double>> embeddings = genAi.embedTexts(chunks);
String sql = """
INSERT INTO restaurant_vectors (id, restaurant_id, chunk_text, embedding)
VALUES (:id, :rid, :chunk, :emb)
""";
for (int i = 0; i < chunks.size(); i++) {
String id = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase();
float[] vec = new float[embeddings.get(i).size()];
for (int j = 0; j < vec.length; j++) {
vec[j] = embeddings.get(i).get(j).floatValue();
}
var params = new MapSqlParameterSource();
params.addValue("id", id);
params.addValue("rid", restaurantId);
params.addValue("chunk", chunks.get(i));
params.addValue("emb", vec);
jdbc.update(sql, params);
}
}
/**
* Build a text chunk for vector embedding from restaurant data.
*/
public static List<String> buildChunks(String name, Map<String, Object> data, String videoTitle) {
var parts = new ArrayList<String>();
parts.add("식당: " + name);
if (data.get("region") != null) parts.add("지역: " + data.get("region"));
if (data.get("cuisine_type") != null) parts.add("음식 종류: " + data.get("cuisine_type"));
if (data.get("foods_mentioned") instanceof List<?> foods && !foods.isEmpty()) {
parts.add("메뉴: " + String.join(", ", foods.stream().map(Object::toString).toList()));
}
if (data.get("evaluation") != null) parts.add("평가: " + data.get("evaluation"));
if (data.get("price_range") != null) parts.add("가격대: " + data.get("price_range"));
parts.add("영상: " + videoTitle);
return List.of(String.join("\n", parts));
}
}

View File

@@ -0,0 +1,118 @@
package com.tasteby.service;
import com.tasteby.domain.VideoDetail;
import com.tasteby.domain.VideoRestaurantLink;
import com.tasteby.domain.VideoSummary;
import com.tasteby.mapper.VideoMapper;
import com.tasteby.util.IdGenerator;
import com.tasteby.util.JsonUtil;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
public class VideoService {
private final VideoMapper mapper;
public VideoService(VideoMapper mapper) {
this.mapper = mapper;
}
public List<VideoSummary> findAll(String status) {
return mapper.findAll(status);
}
public VideoDetail findDetail(String id) {
VideoDetail detail = mapper.findDetail(id);
if (detail == null) return null;
List<VideoRestaurantLink> restaurants = mapper.findVideoRestaurants(id);
detail.setRestaurants(restaurants != null ? restaurants : List.of());
return detail;
}
public void updateTitle(String id, String title) {
mapper.updateTitle(id, title);
}
public void updateStatus(String id, String status) {
mapper.updateStatus(id, status);
}
@Transactional
public void delete(String id) {
mapper.deleteVectorsByVideoOnly(id);
mapper.deleteReviewsByVideoOnly(id);
mapper.deleteFavoritesByVideoOnly(id);
mapper.deleteRestaurantsByVideoOnly(id);
mapper.deleteVideoRestaurants(id);
mapper.deleteVideo(id);
}
@Transactional
public void deleteVideoRestaurant(String videoId, String restaurantId) {
mapper.deleteOneVideoRestaurant(videoId, restaurantId);
mapper.cleanupOrphanVectors(restaurantId);
mapper.cleanupOrphanReviews(restaurantId);
mapper.cleanupOrphanFavorites(restaurantId);
mapper.cleanupOrphanRestaurant(restaurantId);
}
public int saveVideosBatch(String channelId, List<Map<String, Object>> videos) {
Set<String> existing = new HashSet<>(mapper.getExistingVideoIds(channelId));
int saved = 0;
for (var v : videos) {
String videoId = (String) v.get("video_id");
if (existing.contains(videoId)) continue;
String id = IdGenerator.newId();
mapper.insertVideo(id, channelId, videoId,
(String) v.get("title"), (String) v.get("url"), (String) v.get("published_at"));
saved++;
}
return saved;
}
public Set<String> getExistingVideoIds(String channelId) {
return new HashSet<>(mapper.getExistingVideoIds(channelId));
}
public String getLatestVideoDate(String channelId) {
return mapper.getLatestVideoDate(channelId);
}
public List<Map<String, Object>> findPendingVideos(int limit) {
return mapper.findPendingVideos(limit).stream()
.map(JsonUtil::lowerKeys).toList();
}
public List<Map<String, Object>> findVideosForBulkExtract() {
var rows = mapper.findVideosForBulkExtract();
return rows.stream().map(row -> {
var r = JsonUtil.lowerKeys(row);
// Parse CLOB transcript
Object transcript = r.get("transcript_text");
r.put("transcript", JsonUtil.readClob(transcript));
r.remove("transcript_text");
return r;
}).toList();
}
public void updateTranscript(String id, String transcript) {
mapper.updateTranscript(id, transcript);
}
public void updateVideoFields(String id, String status, String transcript, String llmResponse) {
mapper.updateVideoFields(id, status, transcript, llmResponse);
}
public List<Map<String, Object>> findVideosWithoutTranscript() {
var rows = mapper.findVideosWithoutTranscript();
return rows.stream().map(JsonUtil::lowerKeys).toList();
}
public void updateVideoRestaurantFields(String videoId, String restaurantId,
String foodsJson, String evaluation, String guestsJson) {
mapper.updateVideoRestaurantFields(videoId, restaurantId, foodsJson, evaluation, guestsJson);
}
}

View File

@@ -0,0 +1,490 @@
package com.tasteby.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.thoroldvix.api.Transcript;
import io.github.thoroldvix.api.TranscriptContent;
import io.github.thoroldvix.api.TranscriptList;
import io.github.thoroldvix.api.TranscriptApiFactory;
import io.github.thoroldvix.api.YoutubeTranscriptApi;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.Cookie;
import com.microsoft.playwright.options.WaitUntilState;
import com.tasteby.domain.Channel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import java.nio.file.Path;
import java.time.Duration;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Service
public class YouTubeService {
private static final Logger log = LoggerFactory.getLogger(YouTubeService.class);
private static final Pattern DURATION_PATTERN = Pattern.compile("PT(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?");
private final WebClient webClient;
private final ObjectMapper mapper;
private final ChannelService channelService;
private final VideoService videoService;
private final String apiKey;
public YouTubeService(ObjectMapper mapper,
ChannelService channelService,
VideoService videoService,
@Value("${app.google.youtube-api-key}") String apiKey) {
this.webClient = WebClient.builder()
.baseUrl("https://www.googleapis.com/youtube/v3")
.build();
this.mapper = mapper;
this.channelService = channelService;
this.videoService = videoService;
this.apiKey = apiKey;
}
/**
* Fetch videos from a YouTube channel, page by page.
* Returns all pages merged into one list.
*/
public List<Map<String, Object>> fetchChannelVideos(String channelId, String publishedAfter, boolean excludeShorts) {
List<Map<String, Object>> allVideos = new ArrayList<>();
String nextPage = null;
do {
String pageToken = nextPage;
String response = webClient.get()
.uri(uriBuilder -> {
var b = uriBuilder.path("/search")
.queryParam("key", apiKey)
.queryParam("channelId", channelId)
.queryParam("part", "snippet")
.queryParam("order", "date")
.queryParam("maxResults", 50)
.queryParam("type", "video");
if (publishedAfter != null) b.queryParam("publishedAfter", publishedAfter);
if (pageToken != null) b.queryParam("pageToken", pageToken);
return b.build();
})
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofSeconds(30));
try {
JsonNode data = mapper.readTree(response);
List<Map<String, Object>> pageVideos = new ArrayList<>();
for (JsonNode item : data.path("items")) {
String vid = item.path("id").path("videoId").asText();
JsonNode snippet = item.path("snippet");
pageVideos.add(Map.of(
"video_id", vid,
"title", snippet.path("title").asText(),
"published_at", snippet.path("publishedAt").asText(),
"url", "https://www.youtube.com/watch?v=" + vid
));
}
if (excludeShorts && !pageVideos.isEmpty()) {
pageVideos = filterShorts(pageVideos);
}
allVideos.addAll(pageVideos);
nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null;
} catch (Exception e) {
log.error("Failed to parse YouTube API response", e);
break;
}
} while (nextPage != null);
return allVideos;
}
/**
* Filter out YouTube Shorts (<=60s duration).
*/
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());
String response = webClient.get()
.uri(uriBuilder -> uriBuilder.path("/videos")
.queryParam("key", apiKey)
.queryParam("id", ids)
.queryParam("part", "contentDetails")
.build())
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofSeconds(30));
try {
JsonNode data = mapper.readTree(response);
Map<String, Integer> durations = new HashMap<>();
for (JsonNode item : data.path("items")) {
String duration = item.path("contentDetails").path("duration").asText();
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) {
log.warn("Failed to filter shorts", e);
return videos;
}
}
private int parseDuration(String dur) {
Matcher m = DURATION_PATTERN.matcher(dur != null ? dur : "");
if (!m.matches()) return 0;
int h = m.group(1) != null ? Integer.parseInt(m.group(1)) : 0;
int min = m.group(2) != null ? Integer.parseInt(m.group(2)) : 0;
int s = m.group(3) != null ? Integer.parseInt(m.group(3)) : 0;
return h * 3600 + min * 60 + s;
}
/**
* Scan a single channel for new videos. Returns scan result map.
*/
public Map<String, Object> scanChannel(String channelId, boolean full) {
Channel ch = channelService.findByChannelId(channelId);
if (ch == null) return null;
String dbId = ch.getId();
String titleFilter = ch.getTitleFilter();
String after = full ? null : videoService.getLatestVideoDate(dbId);
Set<String> existing = videoService.getExistingVideoIds(dbId);
List<Map<String, Object>> allFetched = fetchChannelVideos(channelId, after, true);
int totalFetched = allFetched.size();
List<Map<String, Object>> candidates = new ArrayList<>();
for (var v : allFetched) {
if (titleFilter != null && !((String) v.get("title")).contains(titleFilter)) continue;
if (existing.contains(v.get("video_id"))) continue;
candidates.add(v);
}
int newCount = videoService.saveVideosBatch(dbId, candidates);
return Map.of(
"total_fetched", totalFetched,
"new_videos", newCount,
"filtered", titleFilter != null ? totalFetched - candidates.size() : 0
);
}
/**
* Scan all active channels. Returns total new video count.
*/
public int scanAllChannels() {
List<Channel> channels = channelService.findAllActive();
int totalNew = 0;
for (var ch : channels) {
try {
var result = scanChannel(ch.getChannelId(), false);
if (result != null) {
totalNew += ((Number) result.get("new_videos")).intValue();
}
} catch (Exception e) {
log.error("Failed to scan channel {}: {}", ch.getChannelName(), e.getMessage());
}
}
return totalNew;
}
public record TranscriptResult(String text, String source) {}
private static final List<String> PREFERRED_LANGS = List.of("ko", "en");
private final YoutubeTranscriptApi transcriptApi = TranscriptApiFactory.createDefault();
/**
* Fetch transcript for a YouTube video.
* Tries API first (fast), then falls back to Playwright browser extraction.
* @param mode "auto" = manual first then generated, "manual" = manual only, "generated" = generated only
*/
public TranscriptResult getTranscript(String videoId, String mode) {
if (mode == null) mode = "auto";
// 1) Fast path: youtube-transcript-api
TranscriptResult apiResult = getTranscriptApi(videoId, mode);
if (apiResult != null) return apiResult;
// 2) Fallback: Playwright browser
log.warn("API failed for {}, trying Playwright browser", videoId);
return getTranscriptBrowser(videoId);
}
private TranscriptResult getTranscriptApi(String videoId, String mode) {
TranscriptList transcriptList;
try {
transcriptList = transcriptApi.listTranscripts(videoId);
} catch (Exception e) {
log.warn("Cannot list transcripts for {}: {}", videoId, e.getMessage());
return null;
}
String[] langs = PREFERRED_LANGS.toArray(String[]::new);
return switch (mode) {
case "manual" -> fetchTranscript(transcriptList, langs, true);
case "generated" -> fetchTranscript(transcriptList, langs, false);
default -> {
// auto: try manual first, then generated
var result = fetchTranscript(transcriptList, langs, true);
if (result != null) yield result;
yield fetchTranscript(transcriptList, langs, false);
}
};
}
private TranscriptResult fetchTranscript(TranscriptList list, String[] langs, boolean manual) {
Transcript picked;
try {
picked = manual ? list.findManualTranscript(langs) : list.findGeneratedTranscript(langs);
} catch (Exception e) {
return null;
}
try {
TranscriptContent content = picked.fetch();
String text = content.getContent().stream()
.map(TranscriptContent.Fragment::getText)
.collect(Collectors.joining(" "));
if (text.isBlank()) return null;
String label = manual ? "manual" : "generated";
return new TranscriptResult(text, label + " (" + picked.getLanguageCode() + ")");
} catch (Exception e) {
log.warn("Failed to fetch transcript for language {}: {}", picked.getLanguageCode(), e.getMessage());
return null;
}
}
// ─── Playwright browser fallback ───────────────────────────────────────────
@SuppressWarnings("unchecked")
private TranscriptResult getTranscriptBrowser(String videoId) {
try (Playwright pw = Playwright.create()) {
BrowserType.LaunchOptions launchOpts = new BrowserType.LaunchOptions()
.setHeadless(false)
.setArgs(List.of("--disable-blink-features=AutomationControlled"));
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})");
log.info("[TRANSCRIPT] Opening YouTube page for {}", videoId);
page.navigate("https://www.youtube.com/watch?v=" + videoId,
new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(30000));
page.waitForTimeout(5000);
// Skip ads if present
skipAds(page);
page.waitForTimeout(2000);
log.info("[TRANSCRIPT] Page loaded, looking for transcript button");
// Click "더보기" (expand description)
page.evaluate("""
() => {
const moreBtn = document.querySelector('tp-yt-paper-button#expand');
if (moreBtn) moreBtn.click();
}
""");
page.waitForTimeout(2000);
// Click transcript button
Object clicked = page.evaluate("""
() => {
// Method 1: aria-label
for (const label of ['스크립트 표시', 'Show transcript']) {
const btns = document.querySelectorAll(`button[aria-label="${label}"]`);
for (const b of btns) { b.click(); return 'aria-label: ' + label; }
}
// Method 2: text content
const allBtns = document.querySelectorAll('button');
for (const b of allBtns) {
const text = b.textContent.trim();
if (text === '스크립트 표시' || text === 'Show transcript') {
b.click();
return 'text: ' + text;
}
}
// Method 3: engagement panel buttons
const engBtns = document.querySelectorAll('ytd-button-renderer button, ytd-button-renderer a');
for (const b of engBtns) {
const text = b.textContent.trim().toLowerCase();
if (text.includes('transcript') || text.includes('스크립트')) {
b.click();
return 'engagement: ' + text;
}
}
return false;
}
""");
log.info("[TRANSCRIPT] Clicked transcript button: {}", clicked);
if (Boolean.FALSE.equals(clicked)) {
Object btnLabels = page.evaluate("""
() => {
const btns = document.querySelectorAll('button[aria-label]');
return Array.from(btns).map(b => b.getAttribute('aria-label')).slice(0, 30);
}
""");
log.warn("[TRANSCRIPT] Transcript button not found. Available buttons: {}", btnLabels);
return null;
}
// Wait for transcript segments to appear (max ~40s)
page.waitForTimeout(3000);
for (int attempt = 0; attempt < 12; attempt++) {
page.waitForTimeout(3000);
Object count = page.evaluate(
"() => document.querySelectorAll('ytd-transcript-segment-renderer').length");
int segCount = count instanceof Number n ? n.intValue() : 0;
log.info("[TRANSCRIPT] Wait {}s: {} segments", (attempt + 1) * 3 + 3, segCount);
if (segCount > 0) break;
}
// Select Korean if available
selectKorean(page);
// Scroll transcript panel and collect segments
Object segmentsObj = page.evaluate("""
async () => {
const container = document.querySelector(
'ytd-transcript-segment-list-renderer #segments-container, ' +
'ytd-transcript-renderer #body'
);
if (!container) {
const segs = document.querySelectorAll('ytd-transcript-segment-renderer');
return Array.from(segs).map(s => {
const txt = s.querySelector('.segment-text, yt-formatted-string.segment-text');
return txt ? txt.textContent.trim() : '';
}).filter(t => t);
}
let prevCount = 0;
for (let i = 0; i < 50; i++) {
container.scrollTop = container.scrollHeight;
await new Promise(r => setTimeout(r, 300));
const segs = document.querySelectorAll('ytd-transcript-segment-renderer');
if (segs.length === prevCount && i > 3) break;
prevCount = segs.length;
}
const segs = document.querySelectorAll('ytd-transcript-segment-renderer');
return Array.from(segs).map(s => {
const txt = s.querySelector('.segment-text, yt-formatted-string.segment-text');
return txt ? txt.textContent.trim() : '';
}).filter(t => t);
}
""");
if (segmentsObj instanceof List<?> segments && !segments.isEmpty()) {
String text = segments.stream()
.map(Object::toString)
.collect(Collectors.joining(" "));
log.info("[TRANSCRIPT] Browser success: {} chars from {} segments", text.length(), segments.size());
return new TranscriptResult(text, "browser");
}
log.warn("[TRANSCRIPT] No segments found via browser for {}", videoId);
return null;
}
} catch (Exception e) {
log.error("[TRANSCRIPT] Playwright failed for {}: {}", videoId, e.getMessage());
return null;
}
}
private void skipAds(Page page) {
for (int i = 0; i < 12; i++) {
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');
if (skipBtn) { skipBtn.click(); return 'skipped'; }
const adOverlay = document.querySelector('.ytp-ad-player-overlay, .ad-showing');
if (adOverlay) return 'playing';
const adBadge = document.querySelector('.ytp-ad-text');
if (adBadge && adBadge.textContent) return 'badge';
return 'none';
}
""");
String status = String.valueOf(adStatus);
if ("none".equals(status)) break;
log.info("[TRANSCRIPT] Ad detected: {}, waiting...", status);
if ("skipped".equals(status)) {
page.waitForTimeout(2000);
break;
}
page.waitForTimeout(5000);
}
}
private void selectKorean(Page page) {
page.evaluate("""
() => {
const menu = document.querySelector('ytd-transcript-renderer ytd-menu-renderer yt-dropdown-menu');
if (!menu) return;
const trigger = menu.querySelector('button, tp-yt-paper-button');
if (trigger) trigger.click();
}
""");
page.waitForTimeout(1000);
page.evaluate("""
() => {
const items = document.querySelectorAll('tp-yt-paper-listbox a, tp-yt-paper-listbox tp-yt-paper-item');
for (const item of items) {
const text = item.textContent.trim();
if (text.includes('한국어') || text.includes('Korean')) {
item.click();
return;
}
}
}
""");
page.waitForTimeout(2000);
}
private void loadCookies(BrowserContext ctx) {
try {
Path cookieFile = Path.of(System.getProperty("user.dir"), "cookies.txt");
if (!cookieFile.toFile().exists()) return;
List<String> lines = java.nio.file.Files.readAllLines(cookieFile);
List<Cookie> cookies = new ArrayList<>();
for (String line : lines) {
if (line.startsWith("#") || line.isBlank()) continue;
String[] parts = line.split("\t");
if (parts.length < 7) continue;
String domain = parts[0];
if (!domain.contains("youtube") && !domain.contains("google")) continue;
cookies.add(new Cookie(parts[5], parts[6])
.setDomain(domain)
.setPath(parts[2])
.setSecure("TRUE".equalsIgnoreCase(parts[3]))
.setHttpOnly(false));
}
if (!cookies.isEmpty()) {
ctx.addCookies(cookies);
log.info("[TRANSCRIPT] Loaded {} cookies", cookies.size());
}
} catch (Exception e) {
log.debug("Failed to load cookies: {}", e.getMessage());
}
}
}

View File

@@ -0,0 +1,42 @@
package com.tasteby.util;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Standardized cuisine type taxonomy.
*/
public final class CuisineTypes {
private CuisineTypes() {}
public static final List<String> ALL = List.of(
"한식|백반/한정식", "한식|국밥/해장국", "한식|찌개/전골/탕", "한식|삼겹살/돼지구이",
"한식|소고기/한우구이", "한식|곱창/막창", "한식|닭/오리구이", "한식|족발/보쌈",
"한식|회/횟집", "한식|해산물", "한식|분식", "한식|면", "한식|죽/죽집",
"한식|순대/순대국", "한식|장어/민물", "한식|주점/포차", "한식|파인다이닝/코스",
"일식|스시/오마카세", "일식|라멘", "일식|돈카츠", "일식|텐동/튀김",
"일식|이자카야", "일식|야키니쿠", "일식|카레", "일식|소바/우동", "일식|파인다이닝/코스",
"중식|중화요리", "중식|마라/훠궈", "중식|딤섬/만두", "중식|양꼬치", "중식|파인다이닝/코스",
"양식|파스타/이탈리안", "양식|스테이크", "양식|햄버거", "양식|피자",
"양식|프렌치", "양식|바베큐", "양식|브런치", "양식|비건/샐러드", "양식|파인다이닝/코스",
"아시아|베트남", "아시아|태국", "아시아|인도/중동", "아시아|동남아기타",
"기타|치킨", "기타|카페/디저트", "기타|베이커리", "기타|뷔페", "기타|퓨전"
);
public static final Set<String> VALID_SET = Set.copyOf(ALL);
public static final List<String> VALID_PREFIXES = List.of(
"한식|", "일식|", "중식|", "양식|", "아시아|", "기타|"
);
public static final String CUISINE_LIST_TEXT = ALL.stream()
.map(c -> " - " + c)
.collect(Collectors.joining("\n"));
public static boolean isValid(String cuisineType) {
if (VALID_SET.contains(cuisineType)) return true;
return VALID_PREFIXES.stream().anyMatch(cuisineType::startsWith);
}
}

View File

@@ -0,0 +1,12 @@
package com.tasteby.util;
import java.util.UUID;
public final class IdGenerator {
private IdGenerator() {}
public static String newId() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase();
}
}

View File

@@ -0,0 +1,84 @@
package com.tasteby.util;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.Reader;
import java.sql.Clob;
import java.util.*;
import java.util.stream.Collectors;
/**
* JSON utility for handling Oracle CLOB JSON fields.
*/
public final class JsonUtil {
private static final ObjectMapper MAPPER = new ObjectMapper();
private JsonUtil() {}
public static String readClob(Object val) {
if (val == null) return null;
if (val instanceof String s) return s;
if (val instanceof Clob clob) {
try (Reader reader = clob.getCharacterStream()) {
StringBuilder sb = new StringBuilder();
char[] buf = new char[4096];
int len;
while ((len = reader.read(buf)) != -1) {
sb.append(buf, 0, len);
}
return sb.toString();
} catch (Exception e) {
return null;
}
}
return val.toString();
}
public static List<String> parseStringList(Object raw) {
String json = readClob(raw);
if (json == null || json.isBlank()) return Collections.emptyList();
try {
return MAPPER.readValue(json, new TypeReference<>() {});
} catch (Exception e) {
return Collections.emptyList();
}
}
public static Map<String, Object> parseMap(Object raw) {
String json = readClob(raw);
if (json == null || json.isBlank()) return Collections.emptyMap();
try {
return MAPPER.readValue(json, new TypeReference<>() {});
} catch (Exception e) {
return Collections.emptyMap();
}
}
/**
* Convert Oracle uppercase column keys to lowercase (e.g. "ID" → "id", "CUISINE_TYPE" → "cuisine_type").
*/
public static Map<String, Object> lowerKeys(Map<String, Object> row) {
if (row == null) return null;
var result = new LinkedHashMap<String, Object>(row.size());
for (var entry : row.entrySet()) {
result.put(entry.getKey().toLowerCase(), entry.getValue());
}
return result;
}
public static List<Map<String, Object>> lowerKeys(List<Map<String, Object>> rows) {
if (rows == null) return null;
return rows.stream().map(JsonUtil::lowerKeys).collect(Collectors.toList());
}
public static String toJson(Object value) {
try {
return MAPPER.writeValueAsString(value);
} catch (JsonProcessingException e) {
return "{}";
}
}
}

View File

@@ -0,0 +1,94 @@
package com.tasteby.util;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Parse address into "country|city|district" format.
*/
public final class RegionParser {
private RegionParser() {}
private static final Map<String, String> CITY_MAP = Map.ofEntries(
Map.entry("서울특별시", "서울"), Map.entry("서울", "서울"),
Map.entry("부산광역시", "부산"), Map.entry("부산", "부산"),
Map.entry("대구광역시", "대구"), Map.entry("대구", "대구"),
Map.entry("인천광역시", "인천"), Map.entry("인천", "인천"),
Map.entry("광주광역시", "광주"), Map.entry("광주", "광주"),
Map.entry("대전광역시", "대전"), Map.entry("대전", "대전"),
Map.entry("울산광역시", "울산"), Map.entry("울산", "울산"),
Map.entry("세종특별자치시", "세종"),
Map.entry("경기도", "경기"), Map.entry("경기", "경기"),
Map.entry("강원특별자치도", "강원"), Map.entry("강원도", "강원"),
Map.entry("충청북도", "충북"), Map.entry("충청남도", "충남"),
Map.entry("전라북도", "전북"), Map.entry("전북특별자치도", "전북"),
Map.entry("전라남도", "전남"),
Map.entry("경상북도", "경북"), Map.entry("경상남도", "경남"),
Map.entry("제주특별자치도", "제주")
);
private static final Pattern KR_PATTERN = Pattern.compile("대한민국\\s+(\\S+)\\s+(\\S+)");
public static String parse(String address) {
if (address == null || address.isBlank()) return null;
String addr = address.trim();
// Japanese
if (addr.startsWith("일본") || addr.contains("Japan")) {
String city = null;
if (addr.contains("Tokyo")) city = "도쿄";
else if (addr.contains("Osaka")) city = "오사카";
else if (addr.contains("Sapporo") || addr.contains("Hokkaido")) city = "삿포로";
else if (addr.contains("Kyoto")) city = "교토";
else if (addr.contains("Fukuoka")) city = "후쿠오카";
return city != null ? "일본|" + city : "일본";
}
// Singapore
if (addr.contains("Singapore") || addr.contains("싱가포르")) {
return "싱가포르";
}
// Korean: "대한민국 시/도 구/시 ..."
if (addr.contains("대한민국")) {
Matcher m = KR_PATTERN.matcher(addr);
if (m.find()) {
String city = CITY_MAP.get(m.group(1));
if (city != null) {
String gu = m.group(2);
if (gu.endsWith("") || gu.endsWith("") || gu.endsWith("")) {
return "한국|" + city + "|" + gu;
}
return "한국|" + city;
}
}
// Search parts
String[] parts = addr.split("\\s+");
for (int i = 0; i < parts.length; i++) {
String city = CITY_MAP.get(parts[i]);
if (city != null) {
String gu = (i > 0 && (parts[i - 1].endsWith("") || parts[i - 1].endsWith("") || parts[i - 1].endsWith("")))
? parts[i - 1] : null;
return gu != null ? "한국|" + city + "|" + gu : "한국|" + city;
}
}
return "한국";
}
// Korean without prefix
String[] parts = addr.split("\\s+");
if (parts.length > 0) {
String city = CITY_MAP.get(parts[0]);
if (city != null) {
if (parts.length > 1 && (parts[1].endsWith("") || parts[1].endsWith("") || parts[1].endsWith(""))) {
return "한국|" + city + "|" + parts[1];
}
return "한국|" + city;
}
}
return null;
}
}

View File

@@ -0,0 +1,71 @@
server:
port: 8000
spring:
threads:
virtual:
enabled: true
datasource:
url: jdbc:oracle:thin:@${ORACLE_DSN}
username: ${ORACLE_USER}
password: ${ORACLE_PASSWORD}
driver-class-name: oracle.jdbc.OracleDriver
hikari:
minimum-idle: 2
maximum-pool-size: 10
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
pool-name: TastebyHikariPool
connection-init-sql: "SELECT 1 FROM DUAL"
data:
redis:
host: ${REDIS_HOST:192.168.0.147}
port: ${REDIS_PORT:6379}
database: ${REDIS_DB:0}
timeout: 2000ms
jackson:
default-property-inclusion: non_null
property-naming-strategy: SNAKE_CASE
serialization:
write-dates-as-timestamps: false
app:
jwt:
secret: ${JWT_SECRET:tasteby-dev-secret-change-me}
expiration-days: 7
cors:
allowed-origins: http://localhost:3000,http://localhost:3001,https://www.tasteby.net,https://tasteby.net
oracle:
wallet-path: ${ORACLE_WALLET:}
oci:
compartment-id: ${OCI_COMPARTMENT_ID}
chat-endpoint: ${OCI_CHAT_ENDPOINT:https://inference.generativeai.us-ashburn-1.oci.oraclecloud.com}
embed-endpoint: ${OCI_GENAI_ENDPOINT:https://inference.generativeai.us-chicago-1.oci.oraclecloud.com}
chat-model-id: ${OCI_CHAT_MODEL_ID}
embed-model-id: ${OCI_EMBED_MODEL_ID:cohere.embed-v4.0}
google:
maps-api-key: ${GOOGLE_MAPS_API_KEY}
youtube-api-key: ${YOUTUBE_DATA_API_KEY}
client-id: ${GOOGLE_CLIENT_ID:635551099330-2l003d3ernjmkqavd4f6s78r8r405iml.apps.googleusercontent.com}
cache:
ttl-seconds: 600
mybatis:
mapper-locations: classpath:mybatis/mapper/*.xml
config-location: classpath:mybatis/mybatis-config.xml
type-aliases-package: com.tasteby.domain
type-handlers-package: com.tasteby.config
logging:
level:
com.tasteby: DEBUG
com.tasteby.mapper: DEBUG

View File

@@ -0,0 +1,44 @@
<?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.ChannelMapper">
<resultMap id="channelResultMap" type="com.tasteby.domain.Channel">
<id property="id" column="id"/>
<result property="channelId" column="channel_id"/>
<result property="channelName" column="channel_name"/>
<result property="titleFilter" column="title_filter"/>
<result property="videoCount" column="video_count"/>
<result property="lastVideoAt" column="last_video_at"/>
</resultMap>
<select id="findAllActive" resultMap="channelResultMap">
SELECT c.id, c.channel_id, c.channel_name, c.title_filter,
(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
FROM channels c
WHERE c.is_active = 1
ORDER BY c.channel_name
</select>
<insert id="insert">
INSERT INTO channels (id, channel_id, channel_name, title_filter)
VALUES (#{id}, #{channelId}, #{channelName}, #{titleFilter})
</insert>
<update id="deactivateByChannelId">
UPDATE channels SET is_active = 0
WHERE channel_id = #{channelId} AND is_active = 1
</update>
<update id="deactivateById">
UPDATE channels SET is_active = 0
WHERE id = #{id} AND is_active = 1
</update>
<select id="findByChannelId" resultMap="channelResultMap">
SELECT id, channel_id, channel_name, title_filter
FROM channels
WHERE channel_id = #{channelId} AND is_active = 1
</select>
</mapper>

View File

@@ -0,0 +1,45 @@
<?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.DaemonConfigMapper">
<resultMap id="daemonConfigResultMap" type="com.tasteby.domain.DaemonConfig">
<id property="id" column="id"/>
<result property="scanEnabled" column="scan_enabled" javaType="boolean"/>
<result property="scanIntervalMin" column="scan_interval_min"/>
<result property="processEnabled" column="process_enabled" javaType="boolean"/>
<result property="processIntervalMin" column="process_interval_min"/>
<result property="processLimit" column="process_limit"/>
<result property="lastScanAt" column="last_scan_at"/>
<result property="lastProcessAt" column="last_process_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<select id="getConfig" resultMap="daemonConfigResultMap">
SELECT id, scan_enabled, scan_interval_min, process_enabled, process_interval_min,
process_limit, last_scan_at, last_process_at, updated_at
FROM daemon_config
WHERE id = 1
</select>
<update id="updateConfig">
UPDATE daemon_config
<set>
scan_enabled = #{scanEnabled, javaType=boolean, jdbcType=NUMERIC},
scan_interval_min = #{scanIntervalMin},
process_enabled = #{processEnabled, javaType=boolean, jdbcType=NUMERIC},
process_interval_min = #{processIntervalMin},
process_limit = #{processLimit},
updated_at = SYSTIMESTAMP,
</set>
WHERE id = 1
</update>
<update id="updateLastScan">
UPDATE daemon_config SET last_scan_at = SYSTIMESTAMP WHERE id = 1
</update>
<update id="updateLastProcess">
UPDATE daemon_config SET last_process_at = SYSTIMESTAMP WHERE id = 1
</update>
</mapper>

View File

@@ -0,0 +1,234 @@
<?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.RestaurantMapper">
<!-- ===== Result Maps ===== -->
<resultMap id="restaurantMap" type="Restaurant">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="address" column="address"/>
<result property="region" column="region"/>
<result property="latitude" column="latitude"/>
<result property="longitude" column="longitude"/>
<result property="cuisineType" column="cuisine_type"/>
<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="businessStatus" column="business_status"/>
<result property="rating" column="rating"/>
<result property="ratingCount" column="rating_count"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<!-- ===== Queries ===== -->
<select id="findAll" resultMap="restaurantMap">
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.business_status, r.rating, r.rating_count, r.updated_at
FROM restaurants r
<if test="channel != null and channel != ''">
JOIN video_restaurants vr_f ON vr_f.restaurant_id = r.id
JOIN videos v_f ON v_f.id = vr_f.video_id
JOIN channels c_f ON c_f.id = v_f.channel_id
</if>
<where>
r.latitude IS NOT NULL
AND EXISTS (SELECT 1 FROM video_restaurants vr0 WHERE vr0.restaurant_id = r.id)
<if test="cuisine != null and cuisine != ''">
AND r.cuisine_type = #{cuisine}
</if>
<if test="region != null and region != ''">
AND r.region LIKE '%' || #{region} || '%'
</if>
<if test="channel != null and channel != ''">
AND c_f.channel_name = #{channel}
</if>
</where>
ORDER BY r.updated_at DESC
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
</select>
<select id="findById" resultMap="restaurantMap">
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.business_status, r.rating, r.rating_count
FROM restaurants r
WHERE r.id = #{id}
</select>
<select id="findVideoLinks" resultType="map">
SELECT v.video_id, v.title, v.url,
TO_CHAR(v.published_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS published_at,
vr.foods_mentioned, vr.evaluation, vr.guests,
c.channel_name, c.channel_id
FROM video_restaurants vr
JOIN videos v ON v.id = vr.video_id
JOIN channels c ON c.id = v.channel_id
WHERE vr.restaurant_id = #{restaurantId}
ORDER BY v.published_at DESC
</select>
<!-- ===== Insert ===== -->
<insert id="insertRestaurant">
INSERT INTO restaurants (id, name, address, region, latitude, longitude,
cuisine_type, price_range, google_place_id,
phone, website, business_status, rating, rating_count)
VALUES (#{id}, #{name}, #{address}, #{region}, #{latitude}, #{longitude},
#{cuisineType}, #{priceRange}, #{googlePlaceId},
#{phone}, #{website}, #{businessStatus}, #{rating}, #{ratingCount})
</insert>
<!-- ===== Update with COALESCE ===== -->
<update id="updateRestaurant">
UPDATE restaurants SET
name = #{name},
address = COALESCE(#{address}, address),
region = COALESCE(#{region}, region),
latitude = COALESCE(#{latitude}, latitude),
longitude = COALESCE(#{longitude}, longitude),
cuisine_type = COALESCE(#{cuisineType}, cuisine_type),
price_range = COALESCE(#{priceRange}, price_range),
google_place_id = COALESCE(#{googlePlaceId}, google_place_id),
phone = COALESCE(#{phone}, phone),
website = COALESCE(#{website}, website),
business_status = COALESCE(#{businessStatus}, business_status),
rating = COALESCE(#{rating}, rating),
rating_count = COALESCE(#{ratingCount}, rating_count),
updated_at = SYSTIMESTAMP
WHERE id = #{id}
</update>
<!-- ===== Dynamic field update ===== -->
<update id="updateFields">
UPDATE restaurants SET
<trim suffixOverrides=",">
<if test="fields.containsKey('name')">
name = #{fields.name},
</if>
<if test="fields.containsKey('address')">
address = #{fields.address},
</if>
<if test="fields.containsKey('region')">
region = #{fields.region},
</if>
<if test="fields.containsKey('cuisine_type')">
cuisine_type = #{fields.cuisine_type},
</if>
<if test="fields.containsKey('price_range')">
price_range = #{fields.price_range},
</if>
<if test="fields.containsKey('phone')">
phone = #{fields.phone},
</if>
<if test="fields.containsKey('website')">
website = #{fields.website},
</if>
<if test="fields.containsKey('latitude')">
latitude = #{fields.latitude},
</if>
<if test="fields.containsKey('longitude')">
longitude = #{fields.longitude},
</if>
updated_at = SYSTIMESTAMP,
</trim>
WHERE id = #{id}
</update>
<!-- ===== Cascade deletes ===== -->
<delete id="deleteVectors">
DELETE FROM restaurant_vectors WHERE restaurant_id = #{id}
</delete>
<delete id="deleteReviews">
DELETE FROM user_reviews WHERE restaurant_id = #{id}
</delete>
<delete id="deleteFavorites">
DELETE FROM user_favorites WHERE restaurant_id = #{id}
</delete>
<delete id="deleteVideoRestaurants">
DELETE FROM video_restaurants WHERE restaurant_id = #{id}
</delete>
<delete id="deleteRestaurant">
DELETE FROM restaurants WHERE id = #{id}
</delete>
<!-- ===== Link video-restaurant ===== -->
<insert id="linkVideoRestaurant">
INSERT INTO video_restaurants (id, video_id, restaurant_id, foods_mentioned, evaluation, guests)
VALUES (#{id}, #{videoId}, #{restaurantId}, #{foods,jdbcType=CLOB}, #{evaluation,jdbcType=CLOB}, #{guests,jdbcType=CLOB})
</insert>
<!-- ===== Lookups ===== -->
<select id="findIdByPlaceId" resultType="string">
SELECT id FROM restaurants WHERE google_place_id = #{placeId} FETCH FIRST 1 ROWS ONLY
</select>
<select id="findIdByName" resultType="string">
SELECT id FROM restaurants WHERE name = #{name} FETCH FIRST 1 ROWS ONLY
</select>
<!-- ===== Batch enrichment queries ===== -->
<select id="findChannelsByRestaurantIds" resultType="map">
SELECT DISTINCT vr.restaurant_id, c.channel_name
FROM video_restaurants vr
JOIN videos v ON v.id = vr.video_id
JOIN channels c ON c.id = v.channel_id
WHERE vr.restaurant_id IN
<foreach item="id" collection="ids" open="(" separator="," close=")">
#{id}
</foreach>
</select>
<select id="findFoodsByRestaurantIds" resultType="map">
SELECT vr.restaurant_id, vr.foods_mentioned
FROM video_restaurants vr
WHERE vr.restaurant_id IN
<foreach item="id" collection="ids" open="(" separator="," close=")">
#{id}
</foreach>
</select>
<!-- ===== Remap operations ===== -->
<update id="updateCuisineType">
UPDATE restaurants SET cuisine_type = #{cuisineType} WHERE id = #{id}
</update>
<update id="updateFoodsMentioned">
UPDATE video_restaurants SET foods_mentioned = #{foods,jdbcType=CLOB} WHERE id = #{id}
</update>
<select id="findForRemapCuisine" resultType="map">
SELECT r.id, r.name, r.cuisine_type,
(SELECT LISTAGG(TO_CHAR(DBMS_LOB.SUBSTR(vr.foods_mentioned, 500, 1)), '|')
WITHIN GROUP (ORDER BY vr.id)
FROM video_restaurants vr WHERE vr.restaurant_id = r.id) AS foods
FROM restaurants r
WHERE EXISTS (SELECT 1 FROM video_restaurants vr2 WHERE vr2.restaurant_id = r.id)
ORDER BY r.name
</select>
<select id="findForRemapFoods" resultType="map">
SELECT vr.id, r.name, r.cuisine_type,
TO_CHAR(vr.foods_mentioned) AS foods_mentioned,
v.title
FROM video_restaurants vr
JOIN restaurants r ON r.id = vr.restaurant_id
JOIN videos v ON v.id = vr.video_id
ORDER BY r.name
</select>
</mapper>

View File

@@ -0,0 +1,130 @@
<?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.ReviewMapper">
<resultMap id="reviewResultMap" type="com.tasteby.domain.Review">
<id property="id" column="id"/>
<result property="userId" column="user_id"/>
<result property="restaurantId" column="restaurant_id"/>
<result property="rating" column="rating"/>
<result property="reviewText" column="review_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="userNickname" column="nickname"/>
<result property="userAvatarUrl" column="avatar_url"/>
<result property="restaurantName" column="restaurant_name"/>
</resultMap>
<resultMap id="restaurantResultMap" type="com.tasteby.domain.Restaurant">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="address" column="address"/>
<result property="region" column="region"/>
<result property="latitude" column="latitude"/>
<result property="longitude" column="longitude"/>
<result property="cuisineType" column="cuisine_type"/>
<result property="priceRange" column="price_range"/>
<result property="googlePlaceId" column="google_place_id"/>
<result property="businessStatus" column="business_status"/>
<result property="rating" column="rating"/>
<result property="ratingCount" column="rating_count"/>
<result property="updatedAt" column="created_at"/>
</resultMap>
<insert id="insertReview">
INSERT INTO user_reviews (id, user_id, restaurant_id, rating, review_text, visited_at)
VALUES (#{id}, #{userId}, #{restaurantId}, #{rating}, #{reviewText},
<choose>
<when test="visitedAt != null">TO_DATE(#{visitedAt}, 'YYYY-MM-DD')</when>
<otherwise>NULL</otherwise>
</choose>)
</insert>
<update id="updateReview">
UPDATE user_reviews SET
rating = COALESCE(#{rating}, rating),
review_text = COALESCE(#{reviewText}, review_text),
visited_at = COALESCE(
<choose>
<when test="visitedAt != null">TO_DATE(#{visitedAt}, 'YYYY-MM-DD')</when>
<otherwise>NULL</otherwise>
</choose>, visited_at),
updated_at = SYSTIMESTAMP
WHERE id = #{id} AND user_id = #{userId}
</update>
<delete id="deleteReview">
DELETE FROM user_reviews WHERE id = #{id} AND user_id = #{userId}
</delete>
<select id="findById" resultMap="reviewResultMap">
SELECT r.id, r.user_id, r.restaurant_id, r.rating, r.review_text,
r.visited_at, r.created_at, r.updated_at,
u.nickname, u.avatar_url
FROM user_reviews r
JOIN tasteby_users u ON u.id = r.user_id
WHERE r.id = #{id}
</select>
<select id="findByRestaurant" resultMap="reviewResultMap">
SELECT r.id, r.user_id, r.restaurant_id, r.rating, r.review_text,
r.visited_at, r.created_at, r.updated_at,
u.nickname, u.avatar_url
FROM user_reviews r
JOIN tasteby_users u ON u.id = r.user_id
WHERE r.restaurant_id = #{restaurantId}
ORDER BY r.created_at DESC
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
</select>
<select id="getAvgRating" resultType="map">
SELECT ROUND(AVG(rating), 1) AS avg_rating, COUNT(*) AS review_count
FROM user_reviews
WHERE restaurant_id = #{restaurantId}
</select>
<select id="findByUser" resultMap="reviewResultMap">
SELECT r.id, r.user_id, r.restaurant_id, r.rating, r.review_text,
r.visited_at, r.created_at, r.updated_at,
u.nickname, u.avatar_url,
rest.name AS restaurant_name
FROM user_reviews r
JOIN tasteby_users u ON u.id = r.user_id
LEFT JOIN restaurants rest ON rest.id = r.restaurant_id
WHERE r.user_id = #{userId}
ORDER BY r.created_at DESC
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
</select>
<select id="countFavorite" resultType="int">
SELECT COUNT(*) FROM user_favorites
WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
</select>
<insert id="insertFavorite">
INSERT INTO user_favorites (id, user_id, restaurant_id)
VALUES (#{id}, #{userId}, #{restaurantId})
</insert>
<delete id="deleteFavorite">
DELETE FROM user_favorites
WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
</delete>
<select id="findFavoriteId" resultType="string">
SELECT id FROM user_favorites
WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
</select>
<select id="getUserFavorites" resultMap="restaurantResultMap">
SELECT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
r.cuisine_type, r.price_range, r.google_place_id,
r.business_status, r.rating, r.rating_count, f.created_at
FROM user_favorites f
JOIN restaurants r ON r.id = f.restaurant_id
WHERE f.user_id = #{userId}
ORDER BY f.created_at DESC
</select>
</mapper>

View File

@@ -0,0 +1,48 @@
<?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.SearchMapper">
<resultMap id="restaurantMap" type="Restaurant">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="address" column="address"/>
<result property="region" column="region"/>
<result property="latitude" column="latitude"/>
<result property="longitude" column="longitude"/>
<result property="cuisineType" column="cuisine_type"/>
<result property="priceRange" column="price_range"/>
<result property="googlePlaceId" column="google_place_id"/>
<result property="businessStatus" column="business_status"/>
<result property="rating" column="rating"/>
<result property="ratingCount" column="rating_count"/>
</resultMap>
<select id="keywordSearch" resultMap="restaurantMap">
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.business_status, r.rating, r.rating_count
FROM restaurants r
JOIN video_restaurants vr ON vr.restaurant_id = r.id
JOIN videos v ON v.id = vr.video_id
WHERE r.latitude IS NOT NULL
AND (UPPER(r.name) LIKE UPPER(#{query})
OR UPPER(r.address) LIKE UPPER(#{query})
OR UPPER(r.region) LIKE UPPER(#{query})
OR UPPER(r.cuisine_type) LIKE UPPER(#{query})
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query})
OR UPPER(v.title) LIKE UPPER(#{query}))
FETCH FIRST #{limit} ROWS ONLY
</select>
<select id="findChannelsByRestaurantIds" resultType="map">
SELECT DISTINCT vr.restaurant_id, c.channel_name
FROM video_restaurants vr
JOIN videos v ON v.id = vr.video_id
JOIN channels c ON c.id = v.channel_id
WHERE vr.restaurant_id IN
<foreach item="id" collection="ids" open="(" separator="," close=")">
#{id}
</foreach>
</select>
</mapper>

View File

@@ -0,0 +1,24 @@
<?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.StatsMapper">
<update id="recordVisit">
MERGE INTO site_visits sv
USING (SELECT TRUNC(SYSDATE) AS d FROM dual) src
ON (sv.visit_date = src.d)
WHEN MATCHED THEN UPDATE SET sv.visit_count = sv.visit_count + 1
WHEN NOT MATCHED THEN INSERT (visit_date, visit_count) VALUES (src.d, 1)
</update>
<select id="getTodayVisits" resultType="int">
SELECT NVL(visit_count, 0)
FROM site_visits
WHERE visit_date = TRUNC(SYSDATE)
</select>
<select id="getTotalVisits" resultType="int">
SELECT NVL(SUM(visit_count), 0)
FROM site_visits
</select>
</mapper>

View File

@@ -0,0 +1,53 @@
<?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.UserMapper">
<resultMap id="userResultMap" type="com.tasteby.domain.UserInfo">
<id property="id" column="id"/>
<result property="email" column="email"/>
<result property="nickname" column="nickname"/>
<result property="avatarUrl" column="avatar_url"/>
<result property="admin" column="is_admin" javaType="boolean"/>
<result property="provider" column="provider"/>
<result property="createdAt" column="created_at"/>
<result property="favoriteCount" column="favorite_count"/>
<result property="reviewCount" column="review_count"/>
</resultMap>
<select id="findByProviderAndProviderId" resultMap="userResultMap">
SELECT id, email, nickname, avatar_url, is_admin, provider, created_at
FROM tasteby_users
WHERE provider = #{provider} AND provider_id = #{providerId}
</select>
<update id="updateLastLogin">
UPDATE tasteby_users SET last_login_at = SYSTIMESTAMP WHERE id = #{id}
</update>
<insert id="insert">
INSERT INTO tasteby_users (id, provider, provider_id, email, nickname, avatar_url)
VALUES (#{id}, #{provider}, #{providerId}, #{email}, #{nickname}, #{avatarUrl})
</insert>
<select id="findById" resultMap="userResultMap">
SELECT id, email, nickname, avatar_url, is_admin, provider, created_at
FROM tasteby_users
WHERE id = #{id}
</select>
<select id="findAllWithCounts" resultMap="userResultMap">
SELECT u.id, u.email, u.nickname, u.avatar_url, u.provider, u.created_at,
NVL(fav.cnt, 0) AS favorite_count,
NVL(rev.cnt, 0) AS review_count
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_reviews GROUP BY user_id) rev ON rev.user_id = u.id
ORDER BY u.created_at DESC
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
</select>
<select id="countAll" resultType="int">
SELECT COUNT(*) FROM tasteby_users
</select>
</mapper>

View File

@@ -0,0 +1,28 @@
<?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.VectorMapper">
<resultMap id="vectorSearchResultMap" type="VectorSearchResult">
<result property="restaurantId" column="restaurant_id"/>
<result property="chunkText" column="chunk_text"
typeHandler="com.tasteby.config.ClobTypeHandler"/>
<result property="distance" column="dist"/>
</resultMap>
<select id="searchSimilar" resultMap="vectorSearchResultMap">
<![CDATA[
SELECT rv.restaurant_id, rv.chunk_text,
VECTOR_DISTANCE(rv.embedding, TO_VECTOR(#{queryVec}), COSINE) AS dist
FROM restaurant_vectors rv
WHERE VECTOR_DISTANCE(rv.embedding, TO_VECTOR(#{queryVec}), COSINE) <= #{maxDistance}
ORDER BY dist
FETCH FIRST #{topK} ROWS ONLY
]]>
</select>
<insert id="insertVector">
INSERT INTO restaurant_vectors (id, restaurant_id, chunk_text, embedding)
VALUES (#{id}, #{restaurantId}, #{chunkText}, TO_VECTOR(#{embedding}))
</insert>
</mapper>

View File

@@ -0,0 +1,235 @@
<?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.VideoMapper">
<!-- ===== Result Maps ===== -->
<resultMap id="videoSummaryMap" type="VideoSummary">
<id property="id" column="id"/>
<result property="videoId" column="video_id"/>
<result property="title" column="title"/>
<result property="url" column="url"/>
<result property="status" column="status"/>
<result property="publishedAt" column="published_at"/>
<result property="channelName" column="channel_name"/>
<result property="hasTranscript" column="has_transcript"/>
<result property="hasLlm" column="has_llm"/>
<result property="restaurantCount" column="restaurant_count"/>
<result property="matchedCount" column="matched_count"/>
</resultMap>
<resultMap id="videoDetailMap" type="VideoDetail">
<id property="id" column="id"/>
<result property="videoId" column="video_id"/>
<result property="title" column="title"/>
<result property="url" column="url"/>
<result property="status" column="status"/>
<result property="publishedAt" column="published_at"/>
<result property="channelName" column="channel_name"/>
<result property="transcriptText" column="transcript_text"
typeHandler="com.tasteby.config.ClobTypeHandler"/>
</resultMap>
<resultMap id="videoRestaurantLinkMap" type="VideoRestaurantLink">
<result property="restaurantId" column="id"/>
<result property="name" column="name"/>
<result property="address" column="address"/>
<result property="cuisineType" column="cuisine_type"/>
<result property="priceRange" column="price_range"/>
<result property="region" column="region"/>
<result property="foodsMentioned" column="foods_mentioned"
typeHandler="com.tasteby.config.ClobTypeHandler"/>
<result property="evaluation" column="evaluation"
typeHandler="com.tasteby.config.ClobTypeHandler"/>
<result property="guests" column="guests"
typeHandler="com.tasteby.config.ClobTypeHandler"/>
<result property="googlePlaceId" column="google_place_id"/>
<result property="latitude" column="latitude"/>
<result property="longitude" column="longitude"/>
</resultMap>
<!-- ===== Queries ===== -->
<select id="findAll" resultMap="videoSummaryMap">
SELECT v.id, v.video_id, v.title, v.url, v.status,
v.published_at, c.channel_name,
CASE WHEN v.transcript_text IS NOT NULL AND dbms_lob.getlength(v.transcript_text) &gt; 0 THEN 1 ELSE 0 END AS has_transcript,
CASE WHEN v.llm_raw_response IS NOT NULL AND dbms_lob.getlength(v.llm_raw_response) &gt; 0 THEN 1 ELSE 0 END AS has_llm,
(SELECT COUNT(*) FROM video_restaurants vr WHERE vr.video_id = v.id) AS restaurant_count,
(SELECT COUNT(*) FROM video_restaurants vr JOIN restaurants r ON r.id = vr.restaurant_id
WHERE vr.video_id = v.id AND r.google_place_id IS NOT NULL) AS matched_count
FROM videos v
JOIN channels c ON c.id = v.channel_id
<if test="status != null and status != ''">
WHERE v.status = #{status}
</if>
ORDER BY v.published_at DESC NULLS LAST
</select>
<select id="findDetail" resultMap="videoDetailMap">
SELECT v.id, v.video_id, v.title, v.url, v.status,
v.published_at, v.transcript_text, c.channel_name
FROM videos v
JOIN channels c ON c.id = v.channel_id
WHERE v.id = #{id}
</select>
<select id="findVideoRestaurants" resultMap="videoRestaurantLinkMap">
SELECT r.id, r.name, r.address, r.cuisine_type, r.price_range, r.region,
vr.foods_mentioned, vr.evaluation, vr.guests,
r.google_place_id, r.latitude, r.longitude
FROM video_restaurants vr
JOIN restaurants r ON r.id = vr.restaurant_id
WHERE vr.video_id = #{videoId}
</select>
<!-- ===== Updates ===== -->
<update id="updateStatus">
UPDATE videos SET status = #{status} WHERE id = #{id}
</update>
<update id="updateTitle">
UPDATE videos SET title = #{title} WHERE id = #{id}
</update>
<update id="updateTranscript">
UPDATE videos SET transcript_text = #{transcript} WHERE id = #{id}
</update>
<update id="updateVideoFields">
UPDATE videos SET
status = #{status},
processed_at = SYSTIMESTAMP
<if test="transcript != null">
, transcript_text = #{transcript}
</if>
<if test="llmResponse != null">
, llm_raw_response = #{llmResponse}
</if>
WHERE id = #{id}
</update>
<!-- ===== Cascade deletes for video deletion ===== -->
<delete id="deleteVectorsByVideoOnly">
DELETE FROM restaurant_vectors WHERE restaurant_id IN (
SELECT vr.restaurant_id FROM video_restaurants vr
WHERE vr.video_id = #{videoId}
AND NOT EXISTS (SELECT 1 FROM video_restaurants vr2
WHERE vr2.restaurant_id = vr.restaurant_id AND vr2.video_id != #{videoId})
)
</delete>
<delete id="deleteReviewsByVideoOnly">
DELETE FROM user_reviews WHERE restaurant_id IN (
SELECT vr.restaurant_id FROM video_restaurants vr
WHERE vr.video_id = #{videoId}
AND NOT EXISTS (SELECT 1 FROM video_restaurants vr2
WHERE vr2.restaurant_id = vr.restaurant_id AND vr2.video_id != #{videoId})
)
</delete>
<delete id="deleteFavoritesByVideoOnly">
DELETE FROM user_favorites WHERE restaurant_id IN (
SELECT vr.restaurant_id FROM video_restaurants vr
WHERE vr.video_id = #{videoId}
AND NOT EXISTS (SELECT 1 FROM video_restaurants vr2
WHERE vr2.restaurant_id = vr.restaurant_id AND vr2.video_id != #{videoId})
)
</delete>
<delete id="deleteRestaurantsByVideoOnly">
DELETE FROM restaurants WHERE id IN (
SELECT vr.restaurant_id FROM video_restaurants vr
WHERE vr.video_id = #{videoId}
AND NOT EXISTS (SELECT 1 FROM video_restaurants vr2
WHERE vr2.restaurant_id = vr.restaurant_id AND vr2.video_id != #{videoId})
)
</delete>
<delete id="deleteVideoRestaurants">
DELETE FROM video_restaurants WHERE video_id = #{videoId}
</delete>
<delete id="deleteVideo">
DELETE FROM videos WHERE id = #{videoId}
</delete>
<!-- ===== Single video-restaurant unlink + orphan cleanup ===== -->
<delete id="deleteOneVideoRestaurant">
DELETE FROM video_restaurants WHERE video_id = #{videoId} AND restaurant_id = #{restaurantId}
</delete>
<delete id="cleanupOrphanVectors">
DELETE FROM restaurant_vectors WHERE restaurant_id = #{restaurantId}
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = #{restaurantId})
</delete>
<delete id="cleanupOrphanReviews">
DELETE FROM user_reviews WHERE restaurant_id = #{restaurantId}
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = #{restaurantId})
</delete>
<delete id="cleanupOrphanFavorites">
DELETE FROM user_favorites WHERE restaurant_id = #{restaurantId}
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = #{restaurantId})
</delete>
<delete id="cleanupOrphanRestaurant">
DELETE FROM restaurants WHERE id = #{restaurantId}
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = #{restaurantId})
</delete>
<!-- ===== Insert / Lookup ===== -->
<insert id="insertVideo">
INSERT INTO videos (id, channel_id, video_id, title, url, published_at)
VALUES (#{id}, #{channelId}, #{videoId}, #{title}, #{url}, #{publishedAt})
</insert>
<select id="getExistingVideoIds" resultType="string">
SELECT video_id FROM videos WHERE channel_id = #{channelId}
</select>
<select id="getLatestVideoDate" resultType="string">
SELECT TO_CHAR(MAX(published_at), 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
FROM videos WHERE channel_id = #{channelId}
</select>
<!-- ===== Pipeline queries ===== -->
<select id="findPendingVideos" resultType="map">
SELECT id, video_id, title, url FROM videos
WHERE status = 'pending' ORDER BY created_at
FETCH FIRST #{limit} ROWS ONLY
</select>
<select id="findVideosForBulkExtract" resultType="map">
SELECT v.id, v.video_id, v.title, v.url, v.transcript_text
FROM videos v
WHERE v.transcript_text IS NOT NULL
AND dbms_lob.getlength(v.transcript_text) &gt; 0
AND (v.llm_raw_response IS NULL OR dbms_lob.getlength(v.llm_raw_response) = 0)
AND v.status != 'skip'
ORDER BY v.published_at DESC
</select>
<select id="findVideosWithoutTranscript" resultType="map">
SELECT id, video_id, title, url
FROM videos
WHERE (transcript_text IS NULL OR dbms_lob.getlength(transcript_text) = 0)
AND status != 'skip'
ORDER BY created_at
</select>
<update id="updateVideoRestaurantFields">
UPDATE video_restaurants
SET foods_mentioned = #{foodsJson,jdbcType=CLOB},
evaluation = #{evaluation,jdbcType=CLOB},
guests = #{guestsJson,jdbcType=CLOB}
WHERE video_id = #{videoId} AND restaurant_id = #{restaurantId}
</update>
</mapper>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<settings>
<setting name="mapUnderscoreToCamelCase" value="true"/>
<setting name="callSettersOnNulls" value="true"/>
<setting name="returnInstanceForEmptyRow" value="true"/>
</settings>
</configuration>

10
backend-java/start.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/bin/bash
export JAVA_HOME="/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home"
export PATH="/opt/homebrew/opt/openjdk@21/bin:$PATH"
# Load env from Python backend (shared env)
set -a
source /Users/joungmin/workspaces/tasteby/backend/.env
set +a
exec ./gradlew bootRun

View File

@@ -30,3 +30,11 @@ def get_optional_user(authorization: str = Header(None)) -> dict | None:
return verify_jwt(token) return verify_jwt(token)
except Exception: except Exception:
return None return None
def get_admin_user(authorization: str = Header(None)) -> dict:
"""Require authenticated admin user. Raises 401/403."""
user = get_current_user(authorization)
if not user.get("is_admin"):
raise HTTPException(403, "관리자 권한이 필요합니다")
return user

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from api.routes import restaurants, channels, videos, search, auth, reviews, admin_users, stats from api.routes import restaurants, channels, videos, search, auth, reviews, admin_users, stats, daemon
app = FastAPI( app = FastAPI(
title="Tasteby API", title="Tasteby API",
@@ -29,6 +29,7 @@ app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(reviews.router, prefix="/api", tags=["reviews"]) app.include_router(reviews.router, prefix="/api", tags=["reviews"])
app.include_router(admin_users.router, prefix="/api/admin/users", tags=["admin-users"]) app.include_router(admin_users.router, prefix="/api/admin/users", tags=["admin-users"])
app.include_router(stats.router, prefix="/api/stats", tags=["stats"]) app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
app.include_router(daemon.router, prefix="/api/daemon", tags=["daemon"])
@app.get("/api/health") @app.get("/api/health")

View File

@@ -36,5 +36,22 @@ def login_google(body: GoogleLoginRequest):
@router.get("/me") @router.get("/me")
def get_me(current_user: dict = Depends(get_current_user)): def get_me(current_user: dict = Depends(get_current_user)):
"""Return current authenticated user info.""" """Return current authenticated user info including admin status."""
return current_user from core.db import conn
user_id = current_user.get("sub") or current_user.get("id")
with conn() as c:
cur = c.cursor()
cur.execute(
"SELECT id, email, nickname, avatar_url, is_admin FROM tasteby_users WHERE id = :id",
{"id": user_id},
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "User not found")
return {
"id": row[0],
"email": row[1],
"nickname": row[2],
"avatar_url": row[3],
"is_admin": bool(row[4]),
}

View File

@@ -5,10 +5,12 @@ from __future__ import annotations
import asyncio import asyncio
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from core import youtube from api.deps import get_admin_user
from core import youtube, cache
_executor = ThreadPoolExecutor(max_workers=4) _executor = ThreadPoolExecutor(max_workers=4)
@@ -23,13 +25,20 @@ class ChannelCreate(BaseModel):
@router.get("") @router.get("")
def list_channels(): def list_channels():
return youtube.get_active_channels() key = cache.make_key("channels")
cached = cache.get(key)
if cached is not None:
return cached
result = youtube.get_active_channels()
cache.set(key, result)
return result
@router.post("", status_code=201) @router.post("", status_code=201)
def create_channel(body: ChannelCreate): def create_channel(body: ChannelCreate, _admin: dict = Depends(get_admin_user)):
try: try:
row_id = youtube.add_channel(body.channel_id, body.channel_name, body.title_filter) row_id = youtube.add_channel(body.channel_id, body.channel_name, body.title_filter)
cache.flush()
return {"id": row_id, "channel_id": body.channel_id} return {"id": row_id, "channel_id": body.channel_id}
except Exception as e: except Exception as e:
if "UQ_CHANNELS_CID" in str(e).upper(): if "UQ_CHANNELS_CID" in str(e).upper():
@@ -63,12 +72,15 @@ def _do_scan(channel_id: str, full: bool):
if not full and new_in_page == 0 and total_fetched > 50: if not full and new_in_page == 0 and total_fetched > 50:
break break
filtered = total_fetched - len(candidates) - len([v for v in candidates if v["video_id"] in existing_vids])
new_count = youtube.save_videos_batch(ch["id"], candidates) new_count = youtube.save_videos_batch(ch["id"], candidates)
return {"total_fetched": total_fetched, "new_videos": new_count} if new_count > 0:
cache.flush()
return {"total_fetched": total_fetched, "new_videos": new_count, "filtered": filtered if title_filter else 0}
@router.post("/{channel_id}/scan") @router.post("/{channel_id}/scan")
async def scan_channel(channel_id: str, full: bool = False): async def scan_channel(channel_id: str, full: bool = False, _admin: dict = Depends(get_admin_user)):
"""Trigger a scan for new videos from this channel (non-blocking).""" """Trigger a scan for new videos from this channel (non-blocking)."""
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
result = await loop.run_in_executor(_executor, _do_scan, channel_id, full) result = await loop.run_in_executor(_executor, _do_scan, channel_id, full)
@@ -78,7 +90,7 @@ async def scan_channel(channel_id: str, full: bool = False):
@router.delete("/{channel_id:path}") @router.delete("/{channel_id:path}")
def delete_channel(channel_id: str): def delete_channel(channel_id: str, _admin: dict = Depends(get_admin_user)):
"""Deactivate a channel. Accepts channel_id or DB id.""" """Deactivate a channel. Accepts channel_id or DB id."""
deleted = youtube.deactivate_channel(channel_id) deleted = youtube.deactivate_channel(channel_id)
if not deleted: if not deleted:
@@ -86,4 +98,5 @@ def delete_channel(channel_id: str):
deleted = youtube.deactivate_channel_by_db_id(channel_id) deleted = youtube.deactivate_channel_by_db_id(channel_id)
if not deleted: if not deleted:
raise HTTPException(404, "Channel not found") raise HTTPException(404, "Channel not found")
cache.flush()
return {"ok": True} return {"ok": True}

View File

@@ -0,0 +1,98 @@
"""Daemon config & manual trigger API routes."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from api.deps import get_admin_user
from core.db import conn
from core import cache
router = APIRouter()
class DaemonConfigUpdate(BaseModel):
scan_enabled: bool | None = None
scan_interval_min: int | None = None
process_enabled: bool | None = None
process_interval_min: int | None = None
process_limit: int | None = None
@router.get("/config")
def get_config():
"""Get daemon config (read-only for all authenticated users)."""
with conn() as c:
cur = c.cursor()
cur.execute(
"SELECT scan_enabled, scan_interval_min, process_enabled, process_interval_min, "
"process_limit, last_scan_at, last_process_at, updated_at "
"FROM daemon_config WHERE id = 1"
)
row = cur.fetchone()
if not row:
return {}
return {
"scan_enabled": bool(row[0]),
"scan_interval_min": row[1],
"process_enabled": bool(row[2]),
"process_interval_min": row[3],
"process_limit": row[4],
"last_scan_at": str(row[5]) if row[5] else None,
"last_process_at": str(row[6]) if row[6] else None,
"updated_at": str(row[7]) if row[7] else None,
}
@router.put("/config")
def update_config(body: DaemonConfigUpdate, _admin: dict = Depends(get_admin_user)):
"""Update daemon schedule config (admin only)."""
sets = []
params: dict = {}
if body.scan_enabled is not None:
sets.append("scan_enabled = :se")
params["se"] = 1 if body.scan_enabled else 0
if body.scan_interval_min is not None:
sets.append("scan_interval_min = :si")
params["si"] = body.scan_interval_min
if body.process_enabled is not None:
sets.append("process_enabled = :pe")
params["pe"] = 1 if body.process_enabled else 0
if body.process_interval_min is not None:
sets.append("process_interval_min = :pi")
params["pi"] = body.process_interval_min
if body.process_limit is not None:
sets.append("process_limit = :pl")
params["pl"] = body.process_limit
if not sets:
return {"ok": True}
sets.append("updated_at = SYSTIMESTAMP")
sql = f"UPDATE daemon_config SET {', '.join(sets)} WHERE id = 1"
with conn() as c:
c.cursor().execute(sql, params)
return {"ok": True}
@router.post("/run/scan")
def run_scan(_admin: dict = Depends(get_admin_user)):
"""Manually trigger channel scan (admin only)."""
from core.youtube import scan_all_channels
new_count = scan_all_channels()
with conn() as c:
c.cursor().execute("UPDATE daemon_config SET last_scan_at = SYSTIMESTAMP WHERE id = 1")
if new_count > 0:
cache.flush()
return {"ok": True, "new_videos": new_count}
@router.post("/run/process")
def run_process(limit: int = 10, _admin: dict = Depends(get_admin_user)):
"""Manually trigger video processing (admin only)."""
from core.pipeline import process_pending
rest_count = process_pending(limit=limit)
with conn() as c:
c.cursor().execute("UPDATE daemon_config SET last_process_at = SYSTIMESTAMP WHERE id = 1")
if rest_count > 0:
cache.flush()
return {"ok": True, "restaurants_extracted": rest_count}

View File

@@ -2,9 +2,10 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from core import restaurant from api.deps import get_admin_user
from core import restaurant, cache
router = APIRouter() router = APIRouter()
@@ -17,19 +18,30 @@ def list_restaurants(
region: str | None = None, region: str | None = None,
channel: str | None = None, channel: str | None = None,
): ):
return restaurant.get_all(limit=limit, offset=offset, cuisine=cuisine, region=region, channel=channel) key = cache.make_key("restaurants", f"l={limit}", f"o={offset}", f"c={cuisine}", f"r={region}", f"ch={channel}")
cached = cache.get(key)
if cached is not None:
return cached
result = restaurant.get_all(limit=limit, offset=offset, cuisine=cuisine, region=region, channel=channel)
cache.set(key, result)
return result
@router.get("/{restaurant_id}") @router.get("/{restaurant_id}")
def get_restaurant(restaurant_id: str): def get_restaurant(restaurant_id: str):
key = cache.make_key("restaurant", restaurant_id)
cached = cache.get(key)
if cached is not None:
return cached
r = restaurant.get_by_id(restaurant_id) r = restaurant.get_by_id(restaurant_id)
if not r: if not r:
raise HTTPException(404, "Restaurant not found") raise HTTPException(404, "Restaurant not found")
cache.set(key, r)
return r return r
@router.put("/{restaurant_id}") @router.put("/{restaurant_id}")
def update_restaurant(restaurant_id: str, body: dict): def update_restaurant(restaurant_id: str, body: dict, _admin: dict = Depends(get_admin_user)):
from core.db import conn from core.db import conn
r = restaurant.get_by_id(restaurant_id) r = restaurant.get_by_id(restaurant_id)
if not r: if not r:
@@ -49,11 +61,12 @@ def update_restaurant(restaurant_id: str, body: dict):
sql = f"UPDATE restaurants SET {', '.join(sets)} WHERE id = :rid" sql = f"UPDATE restaurants SET {', '.join(sets)} WHERE id = :rid"
with conn() as c: with conn() as c:
c.cursor().execute(sql, params) c.cursor().execute(sql, params)
cache.flush()
return {"ok": True} return {"ok": True}
@router.delete("/{restaurant_id}") @router.delete("/{restaurant_id}")
def delete_restaurant(restaurant_id: str): def delete_restaurant(restaurant_id: str, _admin: dict = Depends(get_admin_user)):
from core.db import conn from core.db import conn
r = restaurant.get_by_id(restaurant_id) r = restaurant.get_by_id(restaurant_id)
if not r: if not r:
@@ -64,12 +77,19 @@ def delete_restaurant(restaurant_id: str):
cur.execute("DELETE FROM user_reviews WHERE restaurant_id = :rid", {"rid": restaurant_id}) cur.execute("DELETE FROM user_reviews WHERE restaurant_id = :rid", {"rid": restaurant_id})
cur.execute("DELETE FROM video_restaurants WHERE restaurant_id = :rid", {"rid": restaurant_id}) cur.execute("DELETE FROM video_restaurants WHERE restaurant_id = :rid", {"rid": restaurant_id})
cur.execute("DELETE FROM restaurants WHERE id = :rid", {"rid": restaurant_id}) cur.execute("DELETE FROM restaurants WHERE id = :rid", {"rid": restaurant_id})
cache.flush()
return {"ok": True} return {"ok": True}
@router.get("/{restaurant_id}/videos") @router.get("/{restaurant_id}/videos")
def get_restaurant_videos(restaurant_id: str): def get_restaurant_videos(restaurant_id: str):
key = cache.make_key("restaurant_videos", restaurant_id)
cached = cache.get(key)
if cached is not None:
return cached
r = restaurant.get_by_id(restaurant_id) r = restaurant.get_by_id(restaurant_id)
if not r: if not r:
raise HTTPException(404, "Restaurant not found") raise HTTPException(404, "Restaurant not found")
return restaurant.get_video_links(restaurant_id) result = restaurant.get_video_links(restaurant_id)
cache.set(key, result)
return result

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from core import restaurant, vector from core import restaurant, vector, cache
from core.db import conn from core.db import conn
router = APIRouter() router = APIRouter()
@@ -17,8 +17,15 @@ def search_restaurants(
limit: int = Query(20, le=100), limit: int = Query(20, le=100),
): ):
"""Search restaurants by keyword, semantic similarity, or hybrid.""" """Search restaurants by keyword, semantic similarity, or hybrid."""
key = cache.make_key("search", f"q={q}", f"m={mode}", f"l={limit}")
cached = cache.get(key)
if cached is not None:
return cached
if mode == "semantic": if mode == "semantic":
return _semantic_search(q, limit) result = _semantic_search(q, limit)
cache.set(key, result)
return result
elif mode == "hybrid": elif mode == "hybrid":
kw = _keyword_search(q, limit) kw = _keyword_search(q, limit)
sem = _semantic_search(q, limit) sem = _semantic_search(q, limit)
@@ -29,21 +36,31 @@ def search_restaurants(
if r["id"] not in seen: if r["id"] not in seen:
merged.append(r) merged.append(r)
seen.add(r["id"]) seen.add(r["id"])
return merged[:limit] result = merged[:limit]
cache.set(key, result)
return result
else: else:
return _keyword_search(q, limit) result = _keyword_search(q, limit)
cache.set(key, result)
return result
def _keyword_search(q: str, limit: int) -> list[dict]: def _keyword_search(q: str, limit: int) -> list[dict]:
# JOIN video_restaurants to also search foods_mentioned and video title
sql = """ sql = """
SELECT id, name, address, region, latitude, longitude, SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
cuisine_type, price_range r.cuisine_type, r.price_range, r.google_place_id,
FROM restaurants r.business_status, r.rating, r.rating_count
WHERE latitude IS NOT NULL FROM restaurants r
AND (UPPER(name) LIKE UPPER(:q) JOIN video_restaurants vr ON vr.restaurant_id = r.id
OR UPPER(address) LIKE UPPER(:q) JOIN videos v ON v.id = vr.video_id
OR UPPER(region) LIKE UPPER(:q) WHERE r.latitude IS NOT NULL
OR UPPER(cuisine_type) LIKE UPPER(:q)) AND (UPPER(r.name) LIKE UPPER(:q)
OR UPPER(r.address) LIKE UPPER(:q)
OR UPPER(r.region) LIKE UPPER(:q)
OR UPPER(r.cuisine_type) LIKE UPPER(:q)
OR UPPER(vr.foods_mentioned) LIKE UPPER(:q)
OR UPPER(v.title) LIKE UPPER(:q))
FETCH FIRST :lim ROWS ONLY FETCH FIRST :lim ROWS ONLY
""" """
pattern = f"%{q}%" pattern = f"%{q}%"
@@ -51,18 +68,56 @@ def _keyword_search(q: str, limit: int) -> list[dict]:
cur = c.cursor() cur = c.cursor()
cur.execute(sql, {"q": pattern, "lim": limit}) cur.execute(sql, {"q": pattern, "lim": limit})
cols = [d[0].lower() for d in cur.description] cols = [d[0].lower() for d in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()] rows = [dict(zip(cols, row)) for row in cur.fetchall()]
# Attach channel names
if rows:
_attach_channels(rows)
return rows
def _semantic_search(q: str, limit: int) -> list[dict]: def _semantic_search(q: str, limit: int) -> list[dict]:
similar = vector.search_similar(q, top_k=limit) similar = vector.search_similar(q, top_k=max(30, limit * 3))
if not similar: if not similar:
return [] return []
rest_ids = list({s["restaurant_id"] for s in similar}) # Deduplicate by restaurant_id, preserving distance order (best first)
seen: set[str] = set()
ordered_ids: list[str] = []
for s in similar:
rid = s["restaurant_id"]
if rid not in seen:
seen.add(rid)
ordered_ids.append(rid)
results = [] results = []
for rid in rest_ids[:limit]: for rid in ordered_ids[:limit]:
r = restaurant.get_by_id(rid) r = restaurant.get_by_id(rid)
if r and r.get("latitude"): if r and r.get("latitude"):
results.append(r) results.append(r)
if results:
_attach_channels(results)
return results return results
def _attach_channels(rows: list[dict]):
"""Attach channel names to each restaurant dict."""
ids = [r["id"] for r in rows]
placeholders = ", ".join(f":id{i}" for i in range(len(ids)))
sql = f"""
SELECT DISTINCT vr.restaurant_id, c.channel_name
FROM video_restaurants vr
JOIN videos v ON v.id = vr.video_id
JOIN channels c ON c.id = v.channel_id
WHERE vr.restaurant_id IN ({placeholders})
"""
params = {f"id{i}": rid for i, rid in enumerate(ids)}
ch_map: dict[str, list[str]] = {}
with conn() as c:
cur = c.cursor()
cur.execute(sql, params)
for row in cur.fetchall():
ch_map.setdefault(row[0], []).append(row[1])
for r in rows:
r["channels"] = ch_map.get(r["id"], [])

View File

@@ -9,11 +9,14 @@ import random
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from fastapi import APIRouter, Query from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from api.deps import get_admin_user
from core.db import conn from core.db import conn
from core.pipeline import process_pending from core.pipeline import process_pending
from core import cache
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -23,11 +26,9 @@ _executor = ThreadPoolExecutor(max_workers=4)
@router.get("") @router.get("")
def list_videos( def list_videos(
status: str | None = None, status: str | None = None,
limit: int = Query(50, le=500),
offset: int = Query(0, ge=0),
): ):
conditions = [] conditions = []
params: dict = {"lim": limit, "off": offset} params: dict = {}
if status: if status:
conditions.append("v.status = :st") conditions.append("v.status = :st")
params["st"] = status params["st"] = status
@@ -44,7 +45,6 @@ def list_videos(
JOIN channels c ON c.id = v.channel_id JOIN channels c ON c.id = v.channel_id
{where} {where}
ORDER BY v.published_at DESC NULLS LAST ORDER BY v.published_at DESC NULLS LAST
OFFSET :off ROWS FETCH NEXT :lim ROWS ONLY
""" """
with conn() as c: with conn() as c:
cur = c.cursor() cur = c.cursor()
@@ -100,7 +100,7 @@ def bulk_extract_pending_count():
@router.post("/bulk-extract") @router.post("/bulk-extract")
def bulk_extract(): def bulk_extract(_admin: dict = Depends(get_admin_user)):
"""Process all unextracted videos with random delays. Streams SSE progress.""" """Process all unextracted videos with random delays. Streams SSE progress."""
from core.pipeline import process_video_extract from core.pipeline import process_video_extract
@@ -131,6 +131,8 @@ def bulk_extract():
logger.error("Bulk extract error for %s: %s", v["video_id"], e) logger.error("Bulk extract error for %s: %s", v["video_id"], e)
yield f"data: {_json.dumps({'type': 'error', 'index': i, 'title': v['title'], 'message': str(e)})}\n\n" yield f"data: {_json.dumps({'type': 'error', 'index': i, 'title': v['title'], 'message': str(e)})}\n\n"
if total_restaurants > 0:
cache.flush()
yield f"data: {_json.dumps({'type': 'complete', 'total': total, 'total_restaurants': total_restaurants})}\n\n" yield f"data: {_json.dumps({'type': 'complete', 'total': total, 'total_restaurants': total_restaurants})}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream") return StreamingResponse(generate(), media_type="text/event-stream")
@@ -159,7 +161,7 @@ def bulk_transcript_pending_count():
@router.post("/bulk-transcript") @router.post("/bulk-transcript")
def bulk_transcript(): def bulk_transcript(_admin: dict = Depends(get_admin_user)):
"""Fetch transcripts for all videos missing them. Streams SSE progress.""" """Fetch transcripts for all videos missing them. Streams SSE progress."""
from core.youtube import get_transcript from core.youtube import get_transcript
@@ -196,11 +198,258 @@ def bulk_transcript():
logger.error("Bulk transcript error for %s: %s", v["video_id"], e) logger.error("Bulk transcript error for %s: %s", v["video_id"], e)
yield f"data: {_json.dumps({'type': 'error', 'index': i, 'title': v['title'], 'message': str(e)})}\n\n" yield f"data: {_json.dumps({'type': 'error', 'index': i, 'title': v['title'], 'message': str(e)})}\n\n"
if success > 0:
cache.flush()
yield f"data: {_json.dumps({'type': 'complete', 'total': total, 'success': success})}\n\n" yield f"data: {_json.dumps({'type': 'complete', 'total': total, 'success': success})}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream") return StreamingResponse(generate(), media_type="text/event-stream")
@router.post("/remap-cuisine")
def remap_cuisine(_admin: dict = Depends(get_admin_user)):
"""Remap all restaurant cuisine_type using LLM. Streams SSE progress."""
from core.cuisine import build_remap_prompt, CUISINE_TYPES, VALID_PREFIXES
from core.extractor import _llm, _parse_json
from core.db import conn as db_conn
BATCH = 20 # restaurants per LLM call (smaller for better accuracy)
def _apply_batch(batch: list[dict], valid_set: set[str]) -> tuple[int, list[dict]]:
"""Run LLM on a batch. Returns (updated_count, missed_items)."""
prompt = build_remap_prompt(batch)
raw = _llm(prompt, max_tokens=4096)
result = _parse_json(raw)
if not isinstance(result, list):
result = []
result_map = {}
for item in result:
rid = item.get("id")
new_type = item.get("cuisine_type")
if rid and new_type:
result_map[rid] = new_type
updated = 0
missed = []
for r in batch:
rid = r["id"]
new_type = result_map.get(rid)
if not new_type:
missed.append(r)
continue
# Accept if exact match or valid prefix
if new_type not in valid_set and not new_type.startswith(VALID_PREFIXES):
missed.append(r)
continue
with db_conn() as c:
c.cursor().execute(
"UPDATE restaurants SET cuisine_type = :ct WHERE id = :id",
{"ct": new_type, "id": rid},
)
updated += 1
return updated, missed
def generate():
sql = """
SELECT r.id, r.name, r.cuisine_type,
(SELECT LISTAGG(vr.foods_mentioned, '|') WITHIN GROUP (ORDER BY vr.id)
FROM video_restaurants vr WHERE vr.restaurant_id = r.id) AS foods
FROM restaurants r
WHERE EXISTS (SELECT 1 FROM video_restaurants vr2 WHERE vr2.restaurant_id = r.id)
ORDER BY r.name
"""
with db_conn() as c:
cur = c.cursor()
cur.execute(sql)
rows = []
for row in cur.fetchall():
foods_raw = row[3].read() if hasattr(row[3], "read") else row[3]
rows.append({"id": row[0], "name": row[1], "cuisine_type": row[2], "foods_mentioned": foods_raw})
total = len(rows)
yield f"data: {_json.dumps({'type': 'start', 'total': total})}\n\n"
valid_set = set(CUISINE_TYPES)
updated = 0
all_missed: list[dict] = []
# Pass 1: process all in batches
for i in range(0, total, BATCH):
batch = rows[i : i + BATCH]
yield f"data: {_json.dumps({'type': 'processing', 'current': min(i + BATCH, total), 'total': total, 'pass': 1})}\n\n"
try:
cnt, missed = _apply_batch(batch, valid_set)
updated += cnt
all_missed.extend(missed)
yield f"data: {_json.dumps({'type': 'batch_done', 'current': min(i + BATCH, total), 'total': total, 'updated': updated, 'missed': len(all_missed)})}\n\n"
except Exception as e:
logger.error("Remap batch error at %d: %s", i, e, exc_info=True)
all_missed.extend(batch)
yield f"data: {_json.dumps({'type': 'error', 'message': str(e), 'current': i})}\n\n"
# Pass 2: retry missed items (smaller batches for accuracy)
if all_missed:
yield f"data: {_json.dumps({'type': 'retry', 'missed': len(all_missed)})}\n\n"
RETRY_BATCH = 10
for i in range(0, len(all_missed), RETRY_BATCH):
batch = all_missed[i : i + RETRY_BATCH]
try:
cnt, _ = _apply_batch(batch, valid_set)
updated += cnt
yield f"data: {_json.dumps({'type': 'batch_done', 'current': min(i + RETRY_BATCH, len(all_missed)), 'total': len(all_missed), 'updated': updated, 'pass': 2})}\n\n"
except Exception as e:
logger.error("Remap retry error at %d: %s", i, e, exc_info=True)
cache.flush()
yield f"data: {_json.dumps({'type': 'complete', 'total': total, 'updated': updated})}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@router.post("/remap-foods")
def remap_foods(_admin: dict = Depends(get_admin_user)):
"""Re-extract foods_mentioned for all video_restaurants using LLM. SSE progress."""
from core.extractor import _llm, _parse_json
from core.db import conn as db_conn
BATCH = 15
_FOODS_PROMPT = """\
아래 식당들의 대표 메뉴 태그를 다시 만들어주세요.
규칙:
- 반드시 한글로 작성
- 각 식당당 최대 10개의 대표 메뉴/음식 태그
- 우선순위: 시그니처 메뉴 > 자주 언급된 메뉴 > 일반 메뉴
- 너무 일반적인 태그(밥, 반찬 등)는 제외
- 모든 식당에 대해 빠짐없이 결과 반환 (총 {count}개)
- JSON 배열만 반환, 설명 없음
- 형식: [{{"id": "식당ID", "foods": ["메뉴1", "메뉴2", ...]}}]
식당 목록:
{restaurants}
JSON 배열:"""
def _apply_batch(batch: list[dict]) -> tuple[int, list[dict]]:
items = [{"id": b["id"], "name": b["name"], "current_foods": b["foods"], "cuisine_type": b.get("cuisine_type")} for b in batch]
prompt = _FOODS_PROMPT.format(
restaurants=_json.dumps(items, ensure_ascii=False),
count=len(items),
)
raw = _llm(prompt, max_tokens=4096)
results = _parse_json(raw)
if not isinstance(results, list):
return 0, batch
result_map: dict[str, list[str]] = {}
for item in results:
if isinstance(item, dict) and "id" in item and "foods" in item:
foods = item["foods"]
if isinstance(foods, list):
# Ensure Korean, max 10
foods = [str(f) for f in foods[:10]]
result_map[item["id"]] = foods
updated = 0
missed = []
for b in batch:
bid = b["id"]
new_foods = result_map.get(bid)
if new_foods is None:
missed.append(b)
continue
with db_conn() as c:
c.cursor().execute(
"UPDATE video_restaurants SET foods_mentioned = :foods WHERE id = :id",
{"foods": _json.dumps(new_foods, ensure_ascii=False), "id": bid},
)
updated += 1
return updated, missed
def generate():
# Fetch all video_restaurants with context
sql = """
SELECT vr.id, r.name, r.cuisine_type,
vr.foods_mentioned, v.title
FROM video_restaurants vr
JOIN restaurants r ON r.id = vr.restaurant_id
JOIN videos v ON v.id = vr.video_id
ORDER BY r.name
"""
with db_conn() as c:
cur = c.cursor()
cur.execute(sql)
rows = []
for row in cur.fetchall():
foods_raw = row[3].read() if hasattr(row[3], "read") else (row[3] or "[]")
try:
foods = _json.loads(foods_raw) if isinstance(foods_raw, str) else foods_raw
except Exception:
foods = []
rows.append({
"id": row[0],
"name": row[1],
"cuisine_type": row[2],
"foods": foods if isinstance(foods, list) else [],
"video_title": row[4],
})
total = len(rows)
yield f"data: {_json.dumps({'type': 'start', 'total': total})}\n\n"
updated = 0
all_missed: list[dict] = []
for i in range(0, total, BATCH):
batch = rows[i : i + BATCH]
yield f"data: {_json.dumps({'type': 'processing', 'current': min(i + BATCH, total), 'total': total})}\n\n"
try:
cnt, missed = _apply_batch(batch)
updated += cnt
all_missed.extend(missed)
yield f"data: {_json.dumps({'type': 'batch_done', 'current': min(i + BATCH, total), 'total': total, 'updated': updated})}\n\n"
except Exception as e:
logger.error("Remap foods error at %d: %s", i, e, exc_info=True)
all_missed.extend(batch)
yield f"data: {_json.dumps({'type': 'error', 'message': str(e), 'current': i})}\n\n"
# Retry missed
if all_missed:
yield f"data: {_json.dumps({'type': 'retry', 'missed': len(all_missed)})}\n\n"
for i in range(0, len(all_missed), 10):
batch = all_missed[i : i + 10]
try:
cnt, _ = _apply_batch(batch)
updated += cnt
except Exception as e:
logger.error("Remap foods retry error: %s", e)
cache.flush()
yield f"data: {_json.dumps({'type': 'complete', 'total': total, 'updated': updated})}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@router.post("/rebuild-vectors")
def rebuild_vectors(_admin: dict = Depends(get_admin_user)):
"""Rebuild all restaurant vector embeddings. Streams SSE progress."""
from core import vector
def generate():
yield f"data: {_json.dumps({'type': 'start'})}\n\n"
try:
for progress in vector.rebuild_all_vectors():
yield f"data: {_json.dumps({'type': progress.get('status', 'progress'), **progress})}\n\n"
cache.flush()
except Exception as e:
logger.error("Rebuild vectors error: %s", e, exc_info=True)
yield f"data: {_json.dumps({'type': 'error', 'message': str(e)})}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
@router.get("/extract/prompt") @router.get("/extract/prompt")
def get_extract_prompt(): def get_extract_prompt():
"""Get the current LLM extraction prompt template.""" """Get the current LLM extraction prompt template."""
@@ -209,11 +458,14 @@ def get_extract_prompt():
def _do_process(limit: int): def _do_process(limit: int):
return {"restaurants_extracted": process_pending(limit)} result = process_pending(limit)
if result > 0:
cache.flush()
return {"restaurants_extracted": result}
@router.post("/process") @router.post("/process")
async def trigger_processing(limit: int = Query(5, le=20)): async def trigger_processing(limit: int = Query(5, le=20), _admin: dict = Depends(get_admin_user)):
"""Manually trigger processing of pending videos (non-blocking).""" """Manually trigger processing of pending videos (non-blocking)."""
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
return await loop.run_in_executor(_executor, _do_process, limit) return await loop.run_in_executor(_executor, _do_process, limit)
@@ -318,11 +570,12 @@ def _do_fetch_transcript(video_db_id: str, mode: str):
{"txt": transcript, "vid": video_db_id}, {"txt": transcript, "vid": video_db_id},
) )
cache.flush()
return {"ok": True, "length": len(transcript), "source": source} return {"ok": True, "length": len(transcript), "source": source}
@router.post("/{video_db_id}/fetch-transcript") @router.post("/{video_db_id}/fetch-transcript")
async def fetch_transcript(video_db_id: str, mode: str = Query("auto")): async def fetch_transcript(video_db_id: str, mode: str = Query("auto"), _admin: dict = Depends(get_admin_user)):
"""Fetch and save transcript for a video (non-blocking).""" """Fetch and save transcript for a video (non-blocking)."""
from fastapi import HTTPException from fastapi import HTTPException
@@ -359,11 +612,12 @@ def _do_extract(video_db_id: str, custom_prompt: str | None):
transcript, transcript,
custom_prompt=custom_prompt, custom_prompt=custom_prompt,
) )
cache.flush()
return {"ok": True, "restaurants_extracted": count} return {"ok": True, "restaurants_extracted": count}
@router.post("/{video_db_id}/extract") @router.post("/{video_db_id}/extract")
async def extract_restaurants_from_video(video_db_id: str, body: dict = None): async def extract_restaurants_from_video(video_db_id: str, body: dict = None, _admin: dict = Depends(get_admin_user)):
"""Run LLM extraction on an existing transcript (non-blocking).""" """Run LLM extraction on an existing transcript (non-blocking)."""
from fastapi import HTTPException from fastapi import HTTPException
custom_prompt = body.get("prompt") if body else None custom_prompt = body.get("prompt") if body else None
@@ -375,7 +629,7 @@ async def extract_restaurants_from_video(video_db_id: str, body: dict = None):
@router.post("/{video_db_id}/skip") @router.post("/{video_db_id}/skip")
def skip_video(video_db_id: str): def skip_video(video_db_id: str, _admin: dict = Depends(get_admin_user)):
"""Mark a video as skipped.""" """Mark a video as skipped."""
from fastapi import HTTPException from fastapi import HTTPException
with conn() as c: with conn() as c:
@@ -386,11 +640,12 @@ def skip_video(video_db_id: str):
) )
if cur.rowcount == 0: if cur.rowcount == 0:
raise HTTPException(404, "Video not found") raise HTTPException(404, "Video not found")
cache.flush()
return {"ok": True} return {"ok": True}
@router.delete("/{video_db_id}") @router.delete("/{video_db_id}")
def delete_video(video_db_id: str): def delete_video(video_db_id: str, _admin: dict = Depends(get_admin_user)):
"""Delete a video and its related data.""" """Delete a video and its related data."""
from core.db import conn as get_conn from core.db import conn as get_conn
with get_conn() as c: with get_conn() as c:
@@ -441,11 +696,12 @@ def delete_video(video_db_id: str):
if cur.rowcount == 0: if cur.rowcount == 0:
from fastapi import HTTPException from fastapi import HTTPException
raise HTTPException(404, "Video not found") raise HTTPException(404, "Video not found")
cache.flush()
return {"ok": True} return {"ok": True}
@router.put("/{video_db_id}") @router.put("/{video_db_id}")
def update_video(video_db_id: str, body: dict): def update_video(video_db_id: str, body: dict, _admin: dict = Depends(get_admin_user)):
"""Update video title.""" """Update video title."""
from fastapi import HTTPException from fastapi import HTTPException
title = body.get("title") title = body.get("title")
@@ -459,11 +715,12 @@ def update_video(video_db_id: str, body: dict):
) )
if cur.rowcount == 0: if cur.rowcount == 0:
raise HTTPException(404, "Video not found") raise HTTPException(404, "Video not found")
cache.flush()
return {"ok": True} return {"ok": True}
@router.delete("/{video_db_id}/restaurants/{restaurant_id}") @router.delete("/{video_db_id}/restaurants/{restaurant_id}")
def delete_video_restaurant(video_db_id: str, restaurant_id: str): def delete_video_restaurant(video_db_id: str, restaurant_id: str, _admin: dict = Depends(get_admin_user)):
"""Delete a video-restaurant mapping. Also cleans up orphaned restaurant.""" """Delete a video-restaurant mapping. Also cleans up orphaned restaurant."""
from fastapi import HTTPException from fastapi import HTTPException
with conn() as c: with conn() as c:
@@ -487,11 +744,12 @@ def delete_video_restaurant(video_db_id: str, restaurant_id: str):
DELETE FROM restaurants WHERE id = :rid DELETE FROM restaurants WHERE id = :rid
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = :rid) AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = :rid)
""", {"rid": restaurant_id}) """, {"rid": restaurant_id})
cache.flush()
return {"ok": True} return {"ok": True}
@router.post("/{video_db_id}/restaurants/manual") @router.post("/{video_db_id}/restaurants/manual")
def add_manual_restaurant(video_db_id: str, body: dict): def add_manual_restaurant(video_db_id: str, body: dict, _admin: dict = Depends(get_admin_user)):
"""Manually add a restaurant and link it to a video.""" """Manually add a restaurant and link it to a video."""
from fastapi import HTTPException from fastapi import HTTPException
from core import restaurant as rest_mod from core import restaurant as rest_mod
@@ -538,11 +796,12 @@ def add_manual_restaurant(video_db_id: str, body: dict):
guests=guests if isinstance(guests, list) else [], guests=guests if isinstance(guests, list) else [],
) )
cache.flush()
return {"ok": True, "restaurant_id": rid, "link_id": link_id} return {"ok": True, "restaurant_id": rid, "link_id": link_id}
@router.put("/{video_db_id}/restaurants/{restaurant_id}") @router.put("/{video_db_id}/restaurants/{restaurant_id}")
def update_video_restaurant(video_db_id: str, restaurant_id: str, body: dict): def update_video_restaurant(video_db_id: str, restaurant_id: str, body: dict, _admin: dict = Depends(get_admin_user)):
"""Update restaurant info linked to a video. """Update restaurant info linked to a video.
If name changed, re-geocode and remap to a new restaurant record. If name changed, re-geocode and remap to a new restaurant record.
@@ -552,6 +811,9 @@ def update_video_restaurant(video_db_id: str, restaurant_id: str, body: dict):
# Check if name changed — need to remap # Check if name changed — need to remap
new_name = body.get("name", "").strip() if "name" in body else None new_name = body.get("name", "").strip() if "name" in body else None
name_changed = False
active_rid = restaurant_id
if new_name: if new_name:
with conn() as c: with conn() as c:
cur = c.cursor() cur = c.cursor()
@@ -560,101 +822,126 @@ def update_video_restaurant(video_db_id: str, restaurant_id: str, body: dict):
old_name = row[0] if row else "" old_name = row[0] if row else ""
if old_name != new_name: if old_name != new_name:
# Name changed: geocode new restaurant, remap name_changed = True
from core import restaurant as rest_mod from core import restaurant as rest_mod
from core.geocoding import geocode_restaurant from core.geocoding import geocode_restaurant
address = body.get("address", "").strip() or body.get("region", "").strip() or "" address = (body.get("address") or "").strip() or (body.get("region") or "").strip() or ""
geo = geocode_restaurant(new_name, address) geo = geocode_restaurant(new_name, address)
if not geo: if not geo:
raise HTTPException(400, f"'{new_name}' 위치를 찾을 수 없습니다.") # Geocode failed — just rename in place without remapping
with conn() as c:
new_rid = rest_mod.upsert( cur = c.cursor()
name=new_name, cur.execute("UPDATE restaurants SET name = :name, updated_at = SYSTIMESTAMP WHERE id = :rid",
address=geo.get("formatted_address") or body.get("address"), {"name": new_name, "rid": restaurant_id})
region=body.get("region"), else:
latitude=geo["latitude"], new_rid = rest_mod.upsert(
longitude=geo["longitude"], name=new_name,
cuisine_type=body.get("cuisine_type"), address=geo.get("formatted_address") or body.get("address"),
price_range=body.get("price_range"), region=body.get("region"),
google_place_id=geo.get("google_place_id"), latitude=geo["latitude"],
phone=geo.get("phone"), longitude=geo["longitude"],
website=geo.get("website"), cuisine_type=body.get("cuisine_type"),
business_status=geo.get("business_status"), price_range=body.get("price_range"),
rating=geo.get("rating"), google_place_id=geo.get("google_place_id"),
rating_count=geo.get("rating_count"), phone=geo.get("phone"),
) website=geo.get("website"),
business_status=geo.get("business_status"),
# Read existing mapping data, delete old, create new rating=geo.get("rating"),
with conn() as c: rating_count=geo.get("rating_count"),
cur = c.cursor()
cur.execute(
"SELECT foods_mentioned, evaluation, guests FROM video_restaurants WHERE video_id = :vid AND restaurant_id = :rid",
{"vid": video_db_id, "rid": restaurant_id},
)
old_vr = cur.fetchone()
cur.execute(
"DELETE FROM video_restaurants WHERE video_id = :vid AND restaurant_id = :rid",
{"vid": video_db_id, "rid": restaurant_id},
) )
# Build new mapping values from body or old data # Read existing mapping data, delete old, create new
def _parse(val, default): with conn() as c:
if val is None: cur = c.cursor()
return default cur.execute(
if hasattr(val, "read"): "SELECT foods_mentioned, evaluation, guests FROM video_restaurants WHERE video_id = :vid AND restaurant_id = :rid",
val = val.read() {"vid": video_db_id, "rid": restaurant_id},
if isinstance(val, (list, dict)): )
return val old_vr = cur.fetchone()
try:
return _json.loads(val)
except Exception:
return default
old_foods = _parse(old_vr[0], []) if old_vr else [] cur.execute(
old_eval = _parse(old_vr[1], {}) if old_vr else {} "DELETE FROM video_restaurants WHERE video_id = :vid AND restaurant_id = :rid",
old_guests = _parse(old_vr[2], []) if old_vr else [] {"vid": video_db_id, "rid": restaurant_id},
)
foods = body.get("foods_mentioned", old_foods) def _parse(val, default):
evaluation = body.get("evaluation", old_eval) if val is None:
guests = body.get("guests", old_guests) return default
if hasattr(val, "read"):
val = val.read()
if isinstance(val, (list, dict)):
return val
try:
return _json.loads(val)
except Exception:
return default
eval_text = evaluation.get("text", "") if isinstance(evaluation, dict) else str(evaluation or "") old_foods = _parse(old_vr[0], []) if old_vr else []
old_eval = _parse(old_vr[1], {}) if old_vr else {}
old_guests = _parse(old_vr[2], []) if old_vr else []
rest_mod.link_video_restaurant( foods = body.get("foods_mentioned", old_foods)
video_db_id=video_db_id, evaluation = body.get("evaluation", old_eval)
restaurant_id=new_rid, guests = body.get("guests", old_guests)
foods=foods if isinstance(foods, list) else [],
evaluation=eval_text or None,
guests=guests if isinstance(guests, list) else [],
)
return {"ok": True, "remapped": True, "new_restaurant_id": new_rid} eval_text = evaluation.get("text", "") if isinstance(evaluation, dict) else str(evaluation or "")
# No name change — update in place rest_mod.link_video_restaurant(
with conn() as c: video_db_id=video_db_id,
cur = c.cursor() restaurant_id=new_rid,
r_sets = [] foods=foods if isinstance(foods, list) else [],
r_params: dict = {"rid": restaurant_id} evaluation=eval_text or None,
for field in ("name", "address", "region", "cuisine_type", "price_range"): guests=guests if isinstance(guests, list) else [],
if field in body: )
r_sets.append(f"{field} = :{field}")
r_params[field] = body[field]
if r_sets:
r_sets.append("updated_at = SYSTIMESTAMP")
sql = f"UPDATE restaurants SET {', '.join(r_sets)} WHERE id = :rid"
cur.execute(sql, r_params)
vr_params: dict = {"vid": video_db_id, "rid": restaurant_id} active_rid = new_rid
vr_sets = []
for field in ("foods_mentioned", "evaluation", "guests"):
if field in body:
vr_sets.append(f"{field} = :{field}")
val = body[field]
vr_params[field] = _json.dumps(val, ensure_ascii=False) if isinstance(val, (list, dict)) else val
if vr_sets:
sql = f"UPDATE video_restaurants SET {', '.join(vr_sets)} WHERE video_id = :vid AND restaurant_id = :rid"
cur.execute(sql, vr_params)
return {"ok": True} # 기존 식당이 다른 영상 매핑이 없으면 고아 → 삭제
if new_rid != restaurant_id:
with conn() as c:
cur = c.cursor()
cur.execute(
"SELECT COUNT(*) FROM video_restaurants WHERE restaurant_id = :rid",
{"rid": restaurant_id},
)
remaining = cur.fetchone()[0]
if remaining == 0:
cur.execute("DELETE FROM restaurant_vectors WHERE restaurant_id = :rid", {"rid": restaurant_id})
cur.execute("DELETE FROM user_reviews WHERE restaurant_id = :rid", {"rid": restaurant_id})
cur.execute("DELETE FROM user_favorites WHERE restaurant_id = :rid", {"rid": restaurant_id})
cur.execute("DELETE FROM restaurants WHERE id = :rid", {"rid": restaurant_id})
# Update remaining fields in place (skip name if already remapped)
if not name_changed:
with conn() as c:
cur = c.cursor()
r_sets = []
r_params: dict = {"rid": active_rid}
for field in ("address", "region", "cuisine_type", "price_range"):
if field in body:
r_sets.append(f"{field} = :{field}")
r_params[field] = body[field]
if r_sets:
r_sets.append("updated_at = SYSTIMESTAMP")
sql = f"UPDATE restaurants SET {', '.join(r_sets)} WHERE id = :rid"
cur.execute(sql, r_params)
vr_params: dict = {"vid": video_db_id, "rid": active_rid}
vr_sets = []
for field in ("foods_mentioned", "evaluation", "guests"):
if field in body:
vr_sets.append(f"{field} = :{field}")
val = body[field]
vr_params[field] = _json.dumps(val, ensure_ascii=False) if isinstance(val, (list, dict)) else val
if vr_sets:
sql = f"UPDATE video_restaurants SET {', '.join(vr_sets)} WHERE video_id = :vid AND restaurant_id = :rid"
cur.execute(sql, vr_params)
cache.flush()
result: dict = {"ok": True}
if name_changed:
result["remapped"] = active_rid != restaurant_id
if active_rid != restaurant_id:
result["new_restaurant_id"] = active_rid
return result

View File

@@ -67,6 +67,9 @@ def find_or_create_user(
"email": email, "nickname": nickname, "email": email, "nickname": nickname,
"avatar_url": avatar_url, "id": row[0], "avatar_url": avatar_url, "id": row[0],
}) })
# Fetch is_admin
cur.execute("SELECT is_admin FROM tasteby_users WHERE id = :id", {"id": row[0]})
is_admin = bool(cur.fetchone()[0])
return { return {
"id": row[0], "id": row[0],
"provider": row[1], "provider": row[1],
@@ -74,6 +77,7 @@ def find_or_create_user(
"email": email or row[3], "email": email or row[3],
"nickname": nickname or row[4], "nickname": nickname or row[4],
"avatar_url": avatar_url or row[5], "avatar_url": avatar_url or row[5],
"is_admin": is_admin,
} }
# Create new user # Create new user
@@ -99,6 +103,7 @@ def find_or_create_user(
"email": email, "email": email,
"nickname": nickname, "nickname": nickname,
"avatar_url": avatar_url, "avatar_url": avatar_url,
"is_admin": False,
} }
@@ -108,6 +113,7 @@ def create_jwt(user: dict) -> str:
"sub": user["id"], "sub": user["id"],
"email": user.get("email"), "email": user.get("email"),
"nickname": user.get("nickname"), "nickname": user.get("nickname"),
"is_admin": user.get("is_admin", False),
"exp": datetime.now(timezone.utc) + timedelta(days=JWT_EXPIRE_DAYS), "exp": datetime.now(timezone.utc) + timedelta(days=JWT_EXPIRE_DAYS),
"iat": datetime.now(timezone.utc), "iat": datetime.now(timezone.utc),
} }

107
backend/core/cache.py Normal file
View File

@@ -0,0 +1,107 @@
"""Redis cache layer — graceful fallback when Redis is unavailable."""
from __future__ import annotations
import json
import logging
import os
from typing import Any
import redis
logger = logging.getLogger(__name__)
_client: redis.Redis | None = None
_disabled = False
DEFAULT_TTL = 600 # 10 minutes
def _get_client() -> redis.Redis | None:
global _client, _disabled
if _disabled:
return None
if _client is None:
host = os.environ.get("REDIS_HOST", "192.168.0.147")
port = int(os.environ.get("REDIS_PORT", "6379"))
db = int(os.environ.get("REDIS_DB", "0"))
try:
_client = redis.Redis(
host=host, port=port, db=db,
socket_connect_timeout=2,
socket_timeout=2,
decode_responses=True,
)
_client.ping()
logger.info("Redis connected: %s:%s/%s", host, port, db)
except Exception as e:
logger.warning("Redis unavailable (%s), caching disabled", e)
_client = None
_disabled = True
return None
return _client
def make_key(*parts: Any) -> str:
"""Build a cache key like 'tasteby:restaurants:cuisine=한식:limit=100'."""
return "tasteby:" + ":".join(str(p) for p in parts if p is not None and p != "")
def get(key: str) -> Any | None:
"""Get cached value. Returns None on miss or error."""
try:
client = _get_client()
if not client:
return None
val = client.get(key)
if val is not None:
return json.loads(val)
except Exception as e:
logger.debug("Cache get error: %s", e)
return None
def set(key: str, value: Any, ttl: int = DEFAULT_TTL) -> None:
"""Cache a value as JSON with TTL."""
try:
client = _get_client()
if not client:
return
client.setex(key, ttl, json.dumps(value, ensure_ascii=False, default=str))
except Exception as e:
logger.debug("Cache set error: %s", e)
def flush() -> None:
"""Flush all tasteby cache keys."""
try:
client = _get_client()
if not client:
return
cursor = 0
while True:
cursor, keys = client.scan(cursor, match="tasteby:*", count=200)
if keys:
client.delete(*keys)
if cursor == 0:
break
logger.info("Cache flushed")
except Exception as e:
logger.debug("Cache flush error: %s", e)
def invalidate_prefix(prefix: str) -> None:
"""Delete all keys matching a prefix."""
try:
client = _get_client()
if not client:
return
cursor = 0
while True:
cursor, keys = client.scan(cursor, match=f"{prefix}*", count=200)
if keys:
client.delete(*keys)
if cursor == 0:
break
except Exception as e:
logger.debug("Cache invalidate error: %s", e)

102
backend/core/cuisine.py Normal file
View File

@@ -0,0 +1,102 @@
"""Standardized cuisine type taxonomy and LLM remapping."""
from __future__ import annotations
# ── Canonical cuisine types ──
# Format: "대분류|소분류"
CUISINE_TYPES = [
# 한식
"한식|백반/한정식",
"한식|국밥/해장국",
"한식|찌개/전골/탕",
"한식|삼겹살/돼지구이",
"한식|소고기/한우구이",
"한식|곱창/막창",
"한식|닭/오리구이",
"한식|족발/보쌈",
"한식|회/횟집",
"한식|해산물",
"한식|분식",
"한식|면",
"한식|죽/죽집",
"한식|순대/순대국",
"한식|장어/민물",
"한식|주점/포차",
# 일식
"일식|스시/오마카세",
"일식|라멘",
"일식|돈카츠",
"일식|텐동/튀김",
"일식|이자카야",
"일식|야키니쿠",
"일식|카레",
"일식|소바/우동",
# 중식
"중식|중화요리",
"중식|마라/훠궈",
"중식|딤섬/만두",
"중식|양꼬치",
# 양식
"양식|파스타/이탈리안",
"양식|스테이크",
"양식|햄버거",
"양식|피자",
"양식|프렌치",
"양식|바베큐",
"양식|브런치",
"양식|비건/샐러드",
# 아시아
"아시아|베트남",
"아시아|태국",
"아시아|인도/중동",
"아시아|동남아기타",
# 기타
"기타|치킨",
"기타|카페/디저트",
"기타|베이커리",
"기타|뷔페",
"기타|퓨전",
]
# For LLM prompt
CUISINE_LIST_TEXT = "\n".join(f" - {c}" for c in CUISINE_TYPES)
_REMAP_PROMPT = """\
아래 식당들의 cuisine_type을 표준 분류로 매핑하세요.
표준 분류 목록 (반드시 이 중 하나를 선택):
{cuisine_types}
식당 목록:
{restaurants}
규칙:
- 모든 식당에 대해 빠짐없이 결과를 반환 (총 {count}개 모두 반환해야 함)
- 반드시 위 표준 분류 목록의 값을 그대로 복사하여 사용 (오타 금지)
- 식당 이름, 현재 분류, 메뉴를 종합적으로 고려
- JSON 배열만 반환, 설명 없음
- 형식: [{{"id": "식당ID", "cuisine_type": "한식|국밥/해장국"}}, ...]
JSON 배열:"""
def build_remap_prompt(restaurants: list[dict]) -> str:
"""Build a prompt for remapping cuisine types."""
items = []
for r in restaurants:
items.append({
"id": r["id"],
"name": r["name"],
"current_cuisine_type": r.get("cuisine_type"),
"foods_mentioned": r.get("foods_mentioned"),
})
import json
return _REMAP_PROMPT.format(
cuisine_types=CUISINE_LIST_TEXT,
restaurants=json.dumps(items, ensure_ascii=False),
count=len(items),
)
# Valid prefixes for loose validation
VALID_PREFIXES = ("한식|", "일식|", "중식|", "양식|", "아시아|", "기타|")

View File

@@ -20,6 +20,8 @@ from oci.generative_ai_inference.models import (
UserMessage, UserMessage,
) )
from core.cuisine import CUISINE_LIST_TEXT
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -101,18 +103,22 @@ _EXTRACT_PROMPT = """\
필드: 필드:
- name: 식당 이름 (string, 필수) - name: 식당 이름 (string, 필수)
- address: 주소 또는 위치 힌트 (string | null) - address: 주소 또는 위치 힌트 (string | null)
- region: 지역 (예: 서울 강남, 부산 해운대) (string | null) - region: 지역"나라|시/도|구/군/시" 파이프(|) 구분 형식으로 작성 (string | null)
- cuisine_type: 음식 종류 (예: 한식, 일식, 중식, 양식, 카페) (string | null) - 한국 예시: "한국|서울|강남구", "한국|부산|해운대구", "한국|제주", "한국|강원|강릉시"
- 해외 예시: "일본|도쿄", "일본|오사카", "싱가포르", "미국|뉴욕", "태국|방콕"
- 나라는 한글로, 해외 도시도 한글로 표기
- cuisine_type: 아래 목록에서 가장 적합한 것을 선택 (string, 필수). 반드시 아래 목록 중 하나를 사용:
{cuisine_types}
- price_range: 가격대 (예: 1만원대, 2-3만원) (string | null) - price_range: 가격대 (예: 1만원대, 2-3만원) (string | null)
- foods_mentioned: 언급된 메뉴 (string[]) - foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성)
- evaluation: 평가 내용 (string | null) - evaluation: 평가 내용 (string | null)
- guests: 함께한 게스트 (string[]) - guests: 함께한 게스트 (string[])
영상 제목: {title} 영상 제목: {{title}}
자막: 자막:
{transcript} {{transcript}}
JSON 배열:""" JSON 배열:""".format(cuisine_types=CUISINE_LIST_TEXT)
def extract_restaurants(title: str, transcript: str, custom_prompt: str | None = None) -> tuple[list[dict], str]: def extract_restaurants(title: str, transcript: str, custom_prompt: str | None = None) -> tuple[list[dict], str]:

View File

@@ -3,12 +3,86 @@
from __future__ import annotations from __future__ import annotations
import json import json
import re
import oracledb import oracledb
from core.db import conn from core.db import conn
# ── Region parser: address → "나라|시|구" ──
_CITY_MAP = {
"서울특별시": "서울", "서울": "서울",
"부산광역시": "부산", "부산": "부산",
"대구광역시": "대구", "대구": "대구",
"인천광역시": "인천", "인천": "인천",
"광주광역시": "광주", "광주": "광주",
"대전광역시": "대전", "대전": "대전",
"울산광역시": "울산", "울산": "울산",
"세종특별자치시": "세종",
"경기도": "경기", "경기": "경기",
"강원특별자치도": "강원", "강원도": "강원",
"충청북도": "충북", "충청남도": "충남",
"전라북도": "전북", "전북특별자치도": "전북",
"전라남도": "전남",
"경상북도": "경북", "경상남도": "경남",
"제주특별자치도": "제주",
}
def parse_region_from_address(address: str | None) -> str | None:
"""Parse address into 'country|city|district' format."""
if not address:
return None
addr = address.strip()
# Japanese
if addr.startswith("일본") or "Japan" in addr:
city = None
if "Tokyo" in addr: city = "도쿄"
elif "Osaka" in addr: city = "오사카"
elif "Sapporo" in addr or "Hokkaido" in addr: city = "삿포로"
elif "Kyoto" in addr: city = "교토"
elif "Fukuoka" in addr: city = "후쿠오카"
return f"일본|{city}" if city else "일본"
# Singapore
if "Singapore" in addr or "싱가포르" in addr:
return "싱가포르"
# Korean standard: "대한민국 시/도 구/시 ..."
if "대한민국" in addr:
m = re.match(r"대한민국\s+(\S+)\s+(\S+)", addr)
if m:
city = _CITY_MAP.get(m.group(1))
if city:
gu = m.group(2)
if gu.endswith(("", "", "")):
return f"한국|{city}|{gu}"
# Not a district — just city level
return f"한국|{city}"
# Reversed: "... 구 시 대한민국" / "... 시 KR"
parts = addr.split()
for i, p in enumerate(parts):
if p in _CITY_MAP:
city = _CITY_MAP[p]
gu = parts[i - 1] if i > 0 and parts[i - 1].endswith(("", "", "")) else None
return f"한국|{city}|{gu}" if gu else f"한국|{city}"
return "한국"
# Korean without prefix
parts = addr.split()
if parts:
city = _CITY_MAP.get(parts[0])
if city and len(parts) > 1 and parts[1].endswith(("", "", "")):
return f"한국|{city}|{parts[1]}"
elif city:
return f"한국|{city}"
return None
def _truncate_bytes(val: str | None, max_bytes: int) -> str | None: def _truncate_bytes(val: str | None, max_bytes: int) -> str | None:
"""Truncate a string to fit within max_bytes when encoded as UTF-8.""" """Truncate a string to fit within max_bytes when encoded as UTF-8."""
if not val: if not val:
@@ -19,6 +93,21 @@ def _truncate_bytes(val: str | None, max_bytes: int) -> str | None:
return encoded[:max_bytes].decode("utf-8", errors="ignore").rstrip() return encoded[:max_bytes].decode("utf-8", errors="ignore").rstrip()
def find_by_place_id(google_place_id: str) -> dict | None:
"""Find a restaurant by Google Place ID."""
sql = "SELECT id, name, address, region, latitude, longitude FROM restaurants WHERE google_place_id = :gid"
with conn() as c:
cur = c.cursor()
cur.execute(sql, {"gid": google_place_id})
r = cur.fetchone()
if r:
return {
"id": r[0], "name": r[1], "address": r[2],
"region": r[3], "latitude": r[4], "longitude": r[5],
}
return None
def find_by_name(name: str) -> dict | None: def find_by_name(name: str) -> dict | None:
"""Find a restaurant by exact name match.""" """Find a restaurant by exact name match."""
sql = "SELECT id, name, address, region, latitude, longitude FROM restaurants WHERE name = :n" sql = "SELECT id, name, address, region, latitude, longitude FROM restaurants WHERE name = :n"
@@ -50,17 +139,27 @@ def upsert(
rating_count: int | None = None, rating_count: int | None = None,
) -> str: ) -> str:
"""Insert or update a restaurant. Returns row id.""" """Insert or update a restaurant. Returns row id."""
# Auto-derive region from address if not provided
if not region and address:
region = parse_region_from_address(address)
# Truncate fields to fit DB column byte limits (VARCHAR2 is byte-based) # Truncate fields to fit DB column byte limits (VARCHAR2 is byte-based)
price_range = _truncate_bytes(price_range, 50) price_range = _truncate_bytes(price_range, 50)
cuisine_type = _truncate_bytes(cuisine_type, 100) cuisine_type = _truncate_bytes(cuisine_type, 100)
region = _truncate_bytes(region, 100) region = _truncate_bytes(region, 100)
website = _truncate_bytes(website, 500) website = _truncate_bytes(website, 500)
existing = find_by_name(name) # 1) google_place_id로 먼저 찾고, 2) 이름으로 찾기
existing = None
if google_place_id:
existing = find_by_place_id(google_place_id)
if not existing:
existing = find_by_name(name)
if existing: if existing:
sql = """ sql = """
UPDATE restaurants UPDATE restaurants
SET address = COALESCE(:addr, address), SET name = :name,
address = COALESCE(:addr, address),
region = COALESCE(:reg, region), region = COALESCE(:reg, region),
latitude = COALESCE(:lat, latitude), latitude = COALESCE(:lat, latitude),
longitude = COALESCE(:lng, longitude), longitude = COALESCE(:lng, longitude),
@@ -77,6 +176,7 @@ def upsert(
""" """
with conn() as c: with conn() as c:
c.cursor().execute(sql, { c.cursor().execute(sql, {
"name": name,
"addr": address, "reg": region, "addr": address, "reg": region,
"lat": latitude, "lng": longitude, "lat": latitude, "lng": longitude,
"cuisine": cuisine_type, "price": price_range, "cuisine": cuisine_type, "price": price_range,
@@ -214,8 +314,31 @@ def get_all(
for row in cur.fetchall(): for row in cur.fetchall():
ch_map.setdefault(row[0], []).append(row[1]) ch_map.setdefault(row[0], []).append(row[1])
# Attach aggregated foods_mentioned for each restaurant
foods_sql = f"""
SELECT vr.restaurant_id, vr.foods_mentioned
FROM video_restaurants vr
WHERE vr.restaurant_id IN ({placeholders})
"""
foods_map: dict[str, list[str]] = {}
with conn() as c:
cur = c.cursor()
cur.execute(foods_sql, ch_params)
for row in cur.fetchall():
raw = row[1].read() if hasattr(row[1], "read") else row[1]
if raw:
try:
items = json.loads(raw) if isinstance(raw, str) else raw
if isinstance(items, list):
for f in items:
if isinstance(f, str) and f not in foods_map.get(row[0], []):
foods_map.setdefault(row[0], []).append(f)
except Exception:
pass
for r in restaurants: for r in restaurants:
r["channels"] = ch_map.get(r["id"], []) r["channels"] = ch_map.get(r["id"], [])
r["foods_mentioned"] = foods_map.get(r["id"], [])[:10]
return restaurants return restaurants

View File

@@ -3,9 +3,12 @@
from __future__ import annotations from __future__ import annotations
import array import array
import json
import logging
import os import os
import oci import oci
import oracledb
from oci.generative_ai_inference import GenerativeAiInferenceClient from oci.generative_ai_inference import GenerativeAiInferenceClient
from oci.generative_ai_inference.models import ( from oci.generative_ai_inference.models import (
EmbedTextDetails, EmbedTextDetails,
@@ -14,6 +17,10 @@ from oci.generative_ai_inference.models import (
from core.db import conn from core.db import conn
logger = logging.getLogger(__name__)
_EMBED_BATCH_SIZE = 96 # Cohere embed v4 max batch size
def _embed_texts(texts: list[str]) -> list[list[float]]: def _embed_texts(texts: list[str]) -> list[list[float]]:
config = oci.config.from_file() config = oci.config.from_file()
@@ -34,10 +41,148 @@ def _embed_texts(texts: list[str]) -> list[list[float]]:
return response.data.embeddings return response.data.embeddings
def _embed_texts_batched(texts: list[str]) -> list[list[float]]:
"""Embed texts in batches to respect API limits."""
all_embeddings: list[list[float]] = []
for i in range(0, len(texts), _EMBED_BATCH_SIZE):
batch = texts[i : i + _EMBED_BATCH_SIZE]
all_embeddings.extend(_embed_texts(batch))
return all_embeddings
def _to_vec(embedding: list[float]) -> array.array: def _to_vec(embedding: list[float]) -> array.array:
return array.array("f", embedding) return array.array("f", embedding)
def _parse_json_field(val, default):
if val is None:
return default
if isinstance(val, (list, dict)):
return val
if hasattr(val, "read"):
val = val.read()
if isinstance(val, str):
try:
return json.loads(val)
except (json.JSONDecodeError, ValueError):
return default
return default
def _build_rich_chunk(rest: dict, video_links: list[dict]) -> str:
"""Build a single JSON chunk per restaurant with all relevant info."""
# Collect all foods, evaluations, video titles from linked videos
all_foods: list[str] = []
all_evaluations: list[str] = []
video_titles: list[str] = []
channel_names: set[str] = set()
for vl in video_links:
if vl.get("title"):
video_titles.append(vl["title"])
if vl.get("channel_name"):
channel_names.add(vl["channel_name"])
foods = _parse_json_field(vl.get("foods_mentioned"), [])
if foods:
all_foods.extend(foods)
ev = _parse_json_field(vl.get("evaluation"), {})
if isinstance(ev, dict) and ev.get("text"):
all_evaluations.append(ev["text"])
elif isinstance(ev, str) and ev:
all_evaluations.append(ev)
doc = {
"name": rest.get("name"),
"cuisine_type": rest.get("cuisine_type"),
"region": rest.get("region"),
"address": rest.get("address"),
"price_range": rest.get("price_range"),
"menu": list(dict.fromkeys(all_foods)), # deduplicate, preserve order
"summary": all_evaluations,
"video_titles": video_titles,
"channels": sorted(channel_names),
}
# Remove None/empty values
doc = {k: v for k, v in doc.items() if v}
return json.dumps(doc, ensure_ascii=False)
def rebuild_all_vectors():
"""Rebuild vector embeddings for ALL restaurants.
Yields progress dicts: {"status": "progress", "current": N, "total": M, "name": "..."}
Final yield: {"status": "done", "total": N}
"""
# 1. Get all restaurants with video links
sql_restaurants = """
SELECT DISTINCT r.id, r.name, r.address, r.region, r.cuisine_type, r.price_range
FROM restaurants r
JOIN video_restaurants vr ON vr.restaurant_id = r.id
WHERE r.latitude IS NOT NULL
ORDER BY r.name
"""
sql_video_links = """
SELECT v.title, vr.foods_mentioned, vr.evaluation, c.channel_name
FROM video_restaurants vr
JOIN videos v ON v.id = vr.video_id
JOIN channels c ON c.id = v.channel_id
WHERE vr.restaurant_id = :rid
"""
# Load all restaurant data
restaurants_data: list[tuple[dict, str]] = [] # (rest_dict, chunk_text)
with conn() as c:
cur = c.cursor()
cur.execute(sql_restaurants)
cols = [d[0].lower() for d in cur.description]
all_rests = [dict(zip(cols, row)) for row in cur.fetchall()]
total = len(all_rests)
logger.info("Rebuilding vectors for %d restaurants", total)
for i, rest in enumerate(all_rests):
with conn() as c:
cur = c.cursor()
cur.execute(sql_video_links, {"rid": rest["id"]})
vl_cols = [d[0].lower() for d in cur.description]
video_links = [dict(zip(vl_cols, row)) for row in cur.fetchall()]
chunk = _build_rich_chunk(rest, video_links)
restaurants_data.append((rest, chunk))
yield {"status": "progress", "current": i + 1, "total": total, "phase": "prepare", "name": rest["name"]}
# 2. Delete all existing vectors
with conn() as c:
c.cursor().execute("DELETE FROM restaurant_vectors")
logger.info("Cleared existing vectors")
yield {"status": "progress", "current": 0, "total": total, "phase": "embed"}
# 3. Embed in batches and insert
chunks = [chunk for _, chunk in restaurants_data]
rest_ids = [rest["id"] for rest, _ in restaurants_data]
embeddings = _embed_texts_batched(chunks)
logger.info("Generated %d embeddings", len(embeddings))
insert_sql = """
INSERT INTO restaurant_vectors (restaurant_id, chunk_text, embedding)
VALUES (:rid, :chunk, :emb)
"""
with conn() as c:
cur = c.cursor()
for i, (rid, chunk, emb) in enumerate(zip(rest_ids, chunks, embeddings)):
cur.execute(insert_sql, {
"rid": rid,
"chunk": chunk,
"emb": _to_vec(emb),
})
if (i + 1) % 50 == 0 or i + 1 == total:
yield {"status": "progress", "current": i + 1, "total": total, "phase": "insert"}
logger.info("Rebuilt vectors for %d restaurants", total)
yield {"status": "done", "total": total}
def save_restaurant_vectors(restaurant_id: str, chunks: list[str]) -> list[str]: def save_restaurant_vectors(restaurant_id: str, chunks: list[str]) -> list[str]:
"""Embed and store text chunks for a restaurant. """Embed and store text chunks for a restaurant.
@@ -54,7 +199,6 @@ def save_restaurant_vectors(restaurant_id: str, chunks: list[str]) -> list[str]:
VALUES (:rid, :chunk, :emb) VALUES (:rid, :chunk, :emb)
RETURNING id INTO :out_id RETURNING id INTO :out_id
""" """
import oracledb
with conn() as c: with conn() as c:
cur = c.cursor() cur = c.cursor()
for chunk, emb in zip(chunks, embeddings): for chunk, emb in zip(chunks, embeddings):
@@ -69,10 +213,11 @@ def save_restaurant_vectors(restaurant_id: str, chunks: list[str]) -> list[str]:
return inserted return inserted
def search_similar(query: str, top_k: int = 10) -> list[dict]: def search_similar(query: str, top_k: int = 10, max_distance: float = 0.57) -> list[dict]:
"""Semantic search: find restaurants similar to query text. """Semantic search: find restaurants similar to query text.
Returns list of dicts: restaurant_id, chunk_text, distance. Returns list of dicts: restaurant_id, chunk_text, distance.
Only results with cosine distance <= max_distance are returned.
""" """
embeddings = _embed_texts([query]) embeddings = _embed_texts([query])
query_vec = _to_vec(embeddings[0]) query_vec = _to_vec(embeddings[0])
@@ -81,12 +226,13 @@ def search_similar(query: str, top_k: int = 10) -> list[dict]:
SELECT rv.restaurant_id, rv.chunk_text, SELECT rv.restaurant_id, rv.chunk_text,
VECTOR_DISTANCE(rv.embedding, :qvec, COSINE) AS dist VECTOR_DISTANCE(rv.embedding, :qvec, COSINE) AS dist
FROM restaurant_vectors rv FROM restaurant_vectors rv
WHERE VECTOR_DISTANCE(rv.embedding, :qvec2, COSINE) <= :max_dist
ORDER BY dist ORDER BY dist
FETCH FIRST :k ROWS ONLY FETCH FIRST :k ROWS ONLY
""" """
with conn() as c: with conn() as c:
cur = c.cursor() cur = c.cursor()
cur.execute(sql, {"qvec": query_vec, "k": top_k}) cur.execute(sql, {"qvec": query_vec, "qvec2": query_vec, "k": top_k, "max_dist": max_distance})
return [ return [
{ {
"restaurant_id": r[0], "restaurant_id": r[0],

View File

@@ -72,12 +72,22 @@ def deactivate_channel_by_db_id(db_id: str) -> bool:
def get_active_channels() -> list[dict]: def get_active_channels() -> list[dict]:
sql = "SELECT id, channel_id, channel_name, title_filter FROM channels WHERE is_active = 1" sql = """
SELECT c.id, c.channel_id, c.channel_name, c.title_filter,
(SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id) as video_count,
(SELECT MAX(v.created_at) FROM videos v WHERE v.channel_id = c.id) as last_scanned_at
FROM channels c
WHERE c.is_active = 1
"""
with conn() as c: with conn() as c:
cur = c.cursor() cur = c.cursor()
cur.execute(sql) cur.execute(sql)
return [ return [
{"id": r[0], "channel_id": r[1], "channel_name": r[2], "title_filter": r[3]} {
"id": r[0], "channel_id": r[1], "channel_name": r[2], "title_filter": r[3],
"video_count": r[4] or 0,
"last_scanned_at": r[5].isoformat() if r[5] else None,
}
for r in cur.fetchall() for r in cur.fetchall()
] ]
@@ -99,13 +109,48 @@ def get_latest_video_date(channel_db_id: str) -> str | None:
return None return None
def _parse_iso8601_duration(dur: str) -> int:
"""Parse ISO 8601 duration (e.g. PT1M30S, PT5M, PT1H2M) to seconds."""
import re
m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", dur or "")
if not m:
return 0
h, mn, s = (int(x) if x else 0 for x in m.groups())
return h * 3600 + mn * 60 + s
def _filter_shorts(videos: list[dict]) -> list[dict]:
"""Filter out YouTube Shorts (<=60s) by checking video durations via API."""
if not videos:
return videos
video_ids = [v["video_id"] for v in videos]
r = httpx.get(
"https://www.googleapis.com/youtube/v3/videos",
params={
"key": _api_key(),
"id": ",".join(video_ids),
"part": "contentDetails",
},
timeout=30,
)
r.raise_for_status()
durations = {}
for item in r.json().get("items", []):
durations[item["id"]] = _parse_iso8601_duration(
item.get("contentDetails", {}).get("duration", "")
)
return [v for v in videos if durations.get(v["video_id"], 0) > 60]
def fetch_channel_videos_iter( def fetch_channel_videos_iter(
channel_id: str, channel_id: str,
published_after: str | None = None, published_after: str | None = None,
exclude_shorts: bool = True,
): ):
"""Yield pages of videos from a YouTube channel via Data API v3. """Yield pages of videos from a YouTube channel via Data API v3.
Each yield is a list of dicts for one API page (up to 50). Each yield is a list of dicts for one API page (up to 50).
If exclude_shorts is True, filters out videos <= 60 seconds.
""" """
params: dict = { params: dict = {
"key": _api_key(), "key": _api_key(),
@@ -127,7 +172,7 @@ def fetch_channel_videos_iter(
r = httpx.get( r = httpx.get(
"https://www.googleapis.com/youtube/v3/search", "https://www.googleapis.com/youtube/v3/search",
params=params, params=params,
timeout=15, timeout=30,
) )
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
@@ -143,6 +188,9 @@ def fetch_channel_videos_iter(
"url": f"https://www.youtube.com/watch?v={vid}", "url": f"https://www.youtube.com/watch?v={vid}",
}) })
if page_videos and exclude_shorts:
page_videos = _filter_shorts(page_videos)
if page_videos: if page_videos:
yield page_videos yield page_videos

View File

@@ -1,37 +1,92 @@
"""Daemon worker: periodic channel scan + video processing.""" """Daemon worker: config-driven channel scan + video processing."""
from __future__ import annotations from __future__ import annotations
import logging import logging
import time import time
from datetime import datetime, timedelta
from core.db import conn
from core.youtube import scan_all_channels from core.youtube import scan_all_channels
from core.pipeline import process_pending from core.pipeline import process_pending
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CHECK_INTERVAL = 30 # seconds between config checks
def run_once() -> None:
"""Single daemon cycle: scan channels then process pending videos.""" def _get_config() -> dict | None:
logger.info("=== Daemon cycle start ===") """Read daemon config from DB."""
try: try:
new_count = scan_all_channels() with conn() as c:
logger.info("Scan complete: %d new videos", new_count) cur = c.cursor()
cur.execute(
"SELECT scan_enabled, scan_interval_min, process_enabled, "
"process_interval_min, process_limit, last_scan_at, last_process_at "
"FROM daemon_config WHERE id = 1"
)
row = cur.fetchone()
if not row:
return None
return {
"scan_enabled": bool(row[0]),
"scan_interval_min": row[1],
"process_enabled": bool(row[2]),
"process_interval_min": row[3],
"process_limit": row[4],
"last_scan_at": row[5],
"last_process_at": row[6],
}
except Exception as e: except Exception as e:
logger.error("Channel scan failed: %s", e) logger.error("Failed to read daemon config: %s", e)
return None
try:
rest_count = process_pending(limit=10)
logger.info("Processing complete: %d restaurants extracted", rest_count)
except Exception as e:
logger.error("Video processing failed: %s", e)
logger.info("=== Daemon cycle end ===")
def run_loop(interval: int = 3600) -> None: def _should_run(last_at: datetime | None, interval_min: int) -> bool:
"""Run daemon in a loop with configurable interval (default 1 hour).""" """Check if enough time has passed since last run."""
logger.info("Daemon started (interval=%ds)", interval) if last_at is None:
return True
now = datetime.utcnow()
# Oracle TIMESTAMP comes as datetime
return now - last_at >= timedelta(minutes=interval_min)
def _update_last(field: str) -> None:
"""Update last_scan_at or last_process_at."""
with conn() as c:
c.cursor().execute(
f"UPDATE daemon_config SET {field} = SYSTIMESTAMP WHERE id = 1"
)
def run_once_if_due() -> None:
"""Check config and run tasks if their schedule is due."""
cfg = _get_config()
if not cfg:
return
if cfg["scan_enabled"] and _should_run(cfg["last_scan_at"], cfg["scan_interval_min"]):
logger.info("=== Scheduled scan start ===")
try:
new_count = scan_all_channels()
logger.info("Scan complete: %d new videos", new_count)
_update_last("last_scan_at")
except Exception as e:
logger.error("Channel scan failed: %s", e)
if cfg["process_enabled"] and _should_run(cfg["last_process_at"], cfg["process_interval_min"]):
logger.info("=== Scheduled processing start ===")
try:
rest_count = process_pending(limit=cfg["process_limit"])
logger.info("Processing complete: %d restaurants extracted", rest_count)
_update_last("last_process_at")
except Exception as e:
logger.error("Video processing failed: %s", e)
def run_loop() -> None:
"""Run daemon loop, checking config every CHECK_INTERVAL seconds."""
logger.info("Daemon started (config-driven, check every %ds)", CHECK_INTERVAL)
while True: while True:
run_once() run_once_if_due()
time.sleep(interval) time.sleep(CHECK_INTERVAL)

View File

@@ -1,7 +1,6 @@
"""Run the daemon worker.""" """Run the daemon worker."""
import logging import logging
import os
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
@@ -14,5 +13,4 @@ logging.basicConfig(
) )
if __name__ == "__main__": if __name__ == "__main__":
interval = int(os.environ.get("DAEMON_INTERVAL", "3600")) run_loop()
run_loop(interval)

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}

Some files were not shown because too many files have changed in this diff Show More