Compare commits
31 Commits
3694730501
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ad09e5b67 | ||
|
|
7a896c8c56 | ||
|
|
745913ca5b | ||
|
|
293c59060c | ||
|
|
ff4e8d742d | ||
|
|
69e1882c2b | ||
|
|
16bd83c570 | ||
|
|
c16add08c3 | ||
|
|
91d0ad4598 | ||
|
|
a844fd44cc | ||
|
|
6d05be2331 | ||
|
|
161b1383be | ||
|
|
d39b3b8fea | ||
|
|
b3923dcc72 | ||
|
|
6223691b33 | ||
|
|
99660bf07b | ||
|
|
d1ef156f44 | ||
|
|
4a1c8cf1cd | ||
|
|
2cd72d660a | ||
|
|
17489ad9b0 | ||
|
|
08ea282baf | ||
|
|
237c982e6c | ||
|
|
758d87842b | ||
|
|
d4d516a375 | ||
|
|
54d21afd52 | ||
|
|
a5b3598f8a | ||
|
|
f54da90b5f | ||
|
|
4d09be2419 | ||
|
|
6c47d3c57d | ||
|
|
d6afb62c18 | ||
|
|
2bddb0f764 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
4
backend-java/.dockerignore
Normal file
4
backend-java/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
build/
|
||||||
|
.gradle/
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
16
backend-java/Dockerfile
Normal file
16
backend-java/Dockerfile
Normal 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
71
backend-java/build.gradle
Normal 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()
|
||||||
|
}
|
||||||
BIN
backend-java/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
backend-java/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
backend-java/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
backend-java/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
248
backend-java/gradlew
vendored
Executable 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
93
backend-java/gradlew.bat
vendored
Normal 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
|
||||||
1
backend-java/settings.gradle
Normal file
1
backend-java/settings.gradle
Normal file
@@ -0,0 +1 @@
|
|||||||
|
rootProject.name = 'tasteby-api'
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
32
backend-java/src/main/java/com/tasteby/config/WebConfig.java
Normal file
32
backend-java/src/main/java/com/tasteby/config/WebConfig.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend-java/src/main/java/com/tasteby/domain/Channel.java
Normal file
19
backend-java/src/main/java/com/tasteby/domain/Channel.java
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
24
backend-java/src/main/java/com/tasteby/domain/Review.java
Normal file
24
backend-java/src/main/java/com/tasteby/domain/Review.java
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
25
backend-java/src/main/java/com/tasteby/domain/UserInfo.java
Normal file
25
backend-java/src/main/java/com/tasteby/domain/UserInfo.java
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.tasteby.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface StatsMapper {
|
||||||
|
|
||||||
|
void recordVisit();
|
||||||
|
|
||||||
|
int getTodayVisits();
|
||||||
|
|
||||||
|
int getTotalVisits();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
118
backend-java/src/main/java/com/tasteby/service/VideoService.java
Normal file
118
backend-java/src/main/java/com/tasteby/service/VideoService.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend-java/src/main/java/com/tasteby/util/IdGenerator.java
Normal file
12
backend-java/src/main/java/com/tasteby/util/IdGenerator.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
84
backend-java/src/main/java/com/tasteby/util/JsonUtil.java
Normal file
84
backend-java/src/main/java/com/tasteby/util/JsonUtil.java
Normal 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 "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
backend-java/src/main/resources/application.yml
Normal file
71
backend-java/src/main/resources/application.yml
Normal 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
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
130
backend-java/src/main/resources/mybatis/mapper/ReviewMapper.xml
Normal file
130
backend-java/src/main/resources/mybatis/mapper/ReviewMapper.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
235
backend-java/src/main/resources/mybatis/mapper/VideoMapper.xml
Normal file
235
backend-java/src/main/resources/mybatis/mapper/VideoMapper.xml
Normal 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) > 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) > 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) > 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>
|
||||||
@@ -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
10
backend-java/start.sh
Executable 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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]),
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
98
backend/api/routes/daemon.py
Normal file
98
backend/api/routes/daemon.py
Normal 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}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"], [])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
107
backend/core/cache.py
Normal 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
102
backend/core/cuisine.py
Normal 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 = ("한식|", "일식|", "중식|", "양식|", "아시아|", "기타|")
|
||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
61
build_spec.yaml
Normal 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
Reference in New Issue
Block a user