Migrate backend from Python to Java Spring Boot
- Full Java 21 + Spring Boot 3.3 backend with Virtual Threads - HikariCP connection pool for Oracle ADB - JWT auth, Redis caching, OCI GenAI integration - YouTube transcript extraction via API + Playwright browser fallback - SSE streaming for bulk operations - Scheduled daemon for channel scanning/video processing - Mobile UI: collapse restaurant list to single row on selection - Switch PM2 ecosystem config to Java backend Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
67
backend-java/build.gradle
Normal file
67
backend-java/build.gradle
Normal file
@@ -0,0 +1,67 @@
|
||||
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'
|
||||
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'
|
||||
|
||||
// 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,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,46 @@
|
||||
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
|
||||
.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();
|
||||
}
|
||||
}
|
||||
22
backend-java/src/main/java/com/tasteby/config/WebConfig.java
Normal file
22
backend-java/src/main/java/com/tasteby/config/WebConfig.java
Normal file
@@ -0,0 +1,22 @@
|
||||
package com.tasteby.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Value("${app.cors.allowed-origins}")
|
||||
private String allowedOrigins;
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOrigins(allowedOrigins.split(","))
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.repository.UserRepository;
|
||||
import com.tasteby.repository.ReviewRepository;
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/users")
|
||||
public class AdminUserController {
|
||||
|
||||
private final UserRepository userRepo;
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
|
||||
public AdminUserController(UserRepository userRepo, NamedParameterJdbcTemplate jdbc) {
|
||||
this.userRepo = userRepo;
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public Map<String, Object> listUsers(
|
||||
@RequestParam(defaultValue = "50") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset) {
|
||||
var users = userRepo.findAllWithCounts(limit, offset);
|
||||
int total = userRepo.countAll();
|
||||
return Map.of("users", users, "total", total);
|
||||
}
|
||||
|
||||
@GetMapping("/{userId}/favorites")
|
||||
public List<Map<String, Object>> userFavorites(@PathVariable String userId) {
|
||||
String sql = """
|
||||
SELECT r.id, r.name, r.address, r.region, r.cuisine_type,
|
||||
r.rating, r.business_status, f.created_at
|
||||
FROM user_favorites f
|
||||
JOIN restaurants r ON r.id = f.restaurant_id
|
||||
WHERE f.user_id = :u ORDER BY f.created_at DESC
|
||||
""";
|
||||
return jdbc.queryForList(sql, new MapSqlParameterSource("u", userId));
|
||||
}
|
||||
|
||||
@GetMapping("/{userId}/reviews")
|
||||
public List<Map<String, Object>> userReviews(@PathVariable String userId) {
|
||||
String sql = """
|
||||
SELECT r.id, r.restaurant_id, r.rating, r.review_text,
|
||||
r.visited_at, r.created_at, rest.name AS restaurant_name
|
||||
FROM user_reviews r
|
||||
LEFT JOIN restaurants rest ON rest.id = r.restaurant_id
|
||||
WHERE r.user_id = :u ORDER BY r.created_at DESC
|
||||
""";
|
||||
var rows = jdbc.queryForList(sql, new MapSqlParameterSource("u", userId));
|
||||
rows.forEach(r -> {
|
||||
Object text = r.get("REVIEW_TEXT");
|
||||
r.put("REVIEW_TEXT", JsonUtil.readClob(text));
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
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 Map<String, Object> me() {
|
||||
String userId = AuthUtil.getUserId();
|
||||
return authService.getCurrentUser(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.repository.ChannelRepository;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.CacheService;
|
||||
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 ChannelRepository repo;
|
||||
private final CacheService cache;
|
||||
|
||||
public ChannelController(ChannelRepository repo, CacheService cache) {
|
||||
this.repo = repo;
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Map<String, Object>> list() {
|
||||
String key = cache.makeKey("channels");
|
||||
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<>() {});
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
var result = repo.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 = repo.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 (!repo.deactivate(channelId)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Channel not found");
|
||||
}
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/daemon")
|
||||
public class DaemonController {
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
|
||||
public DaemonController(NamedParameterJdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
@GetMapping("/config")
|
||||
public Map<String, Object> getConfig() {
|
||||
String sql = """
|
||||
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
|
||||
""";
|
||||
var rows = jdbc.queryForList(sql, new MapSqlParameterSource());
|
||||
if (rows.isEmpty()) return Map.of();
|
||||
var row = rows.getFirst();
|
||||
var result = new LinkedHashMap<String, Object>();
|
||||
result.put("scan_enabled", toInt(row.get("SCAN_ENABLED")) == 1);
|
||||
result.put("scan_interval_min", row.get("SCAN_INTERVAL_MIN"));
|
||||
result.put("process_enabled", toInt(row.get("PROCESS_ENABLED")) == 1);
|
||||
result.put("process_interval_min", row.get("PROCESS_INTERVAL_MIN"));
|
||||
result.put("process_limit", row.get("PROCESS_LIMIT"));
|
||||
result.put("last_scan_at", row.get("LAST_SCAN_AT") != null ? row.get("LAST_SCAN_AT").toString() : null);
|
||||
result.put("last_process_at", row.get("LAST_PROCESS_AT") != null ? row.get("LAST_PROCESS_AT").toString() : null);
|
||||
result.put("updated_at", row.get("UPDATED_AT") != null ? row.get("UPDATED_AT").toString() : null);
|
||||
return result;
|
||||
}
|
||||
|
||||
@PutMapping("/config")
|
||||
public Map<String, Object> updateConfig(@RequestBody Map<String, Object> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
var sets = new ArrayList<String>();
|
||||
var params = new MapSqlParameterSource();
|
||||
|
||||
if (body.containsKey("scan_enabled")) {
|
||||
sets.add("scan_enabled = :se");
|
||||
params.addValue("se", Boolean.TRUE.equals(body.get("scan_enabled")) ? 1 : 0);
|
||||
}
|
||||
if (body.containsKey("scan_interval_min")) {
|
||||
sets.add("scan_interval_min = :si");
|
||||
params.addValue("si", ((Number) body.get("scan_interval_min")).intValue());
|
||||
}
|
||||
if (body.containsKey("process_enabled")) {
|
||||
sets.add("process_enabled = :pe");
|
||||
params.addValue("pe", Boolean.TRUE.equals(body.get("process_enabled")) ? 1 : 0);
|
||||
}
|
||||
if (body.containsKey("process_interval_min")) {
|
||||
sets.add("process_interval_min = :pi");
|
||||
params.addValue("pi", ((Number) body.get("process_interval_min")).intValue());
|
||||
}
|
||||
if (body.containsKey("process_limit")) {
|
||||
sets.add("process_limit = :pl");
|
||||
params.addValue("pl", ((Number) body.get("process_limit")).intValue());
|
||||
}
|
||||
if (!sets.isEmpty()) {
|
||||
sets.add("updated_at = SYSTIMESTAMP");
|
||||
String sql = "UPDATE daemon_config SET " + String.join(", ", sets) + " WHERE id = 1";
|
||||
jdbc.update(sql, params);
|
||||
}
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
private int toInt(Object val) {
|
||||
if (val == null) return 0;
|
||||
return ((Number) val).intValue();
|
||||
}
|
||||
}
|
||||
@@ -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,102 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.repository.RestaurantRepository;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.CacheService;
|
||||
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 RestaurantRepository repo;
|
||||
private final CacheService cache;
|
||||
|
||||
public RestaurantController(RestaurantRepository repo, CacheService cache) {
|
||||
this.repo = repo;
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Map<String, Object>> 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 {
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
return mapper.readValue(cached,
|
||||
new com.fasterxml.jackson.core.type.TypeReference<>() {});
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
var result = repo.findAll(limit, offset, cuisine, region, channel);
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public Map<String, Object> get(@PathVariable String id) {
|
||||
String key = cache.makeKey("restaurant", id);
|
||||
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<>() {});
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
var r = repo.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 = repo.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
repo.update(id, body);
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public Map<String, Object> delete(@PathVariable String id) {
|
||||
AuthUtil.requireAdmin();
|
||||
var r = repo.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
repo.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 {
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
return mapper.readValue(cached,
|
||||
new com.fasterxml.jackson.core.type.TypeReference<>() {});
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
var r = repo.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
var result = repo.findVideoLinks(id);
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.repository.ReviewRepository;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
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 ReviewRepository repo;
|
||||
|
||||
public ReviewController(ReviewRepository repo) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
@GetMapping("/restaurants/{restaurantId}/reviews")
|
||||
public Map<String, Object> listRestaurantReviews(
|
||||
@PathVariable String restaurantId,
|
||||
@RequestParam(defaultValue = "20") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset) {
|
||||
var reviews = repo.findByRestaurant(restaurantId, limit, offset);
|
||||
var stats = repo.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 Map<String, Object> 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 repo.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;
|
||||
var result = repo.update(reviewId, userId, rating, text, visitedAt);
|
||||
if (result == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Review not found or not yours");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@DeleteMapping("/reviews/{reviewId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteReview(@PathVariable String reviewId) {
|
||||
String userId = AuthUtil.getUserId();
|
||||
if (!repo.delete(reviewId, userId)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Review not found or not yours");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/users/me/reviews")
|
||||
public List<Map<String, Object>> myReviews(
|
||||
@RequestParam(defaultValue = "20") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset) {
|
||||
return repo.findByUser(AuthUtil.getUserId(), limit, offset);
|
||||
}
|
||||
|
||||
// Favorites
|
||||
@GetMapping("/restaurants/{restaurantId}/favorite")
|
||||
public Map<String, Object> favoriteStatus(@PathVariable String restaurantId) {
|
||||
return Map.of("favorited", repo.isFavorited(AuthUtil.getUserId(), restaurantId));
|
||||
}
|
||||
|
||||
@PostMapping("/restaurants/{restaurantId}/favorite")
|
||||
public Map<String, Object> toggleFavorite(@PathVariable String restaurantId) {
|
||||
boolean result = repo.toggleFavorite(AuthUtil.getUserId(), restaurantId);
|
||||
return Map.of("favorited", result);
|
||||
}
|
||||
|
||||
@GetMapping("/users/me/favorites")
|
||||
public List<Map<String, Object>> myFavorites() {
|
||||
return repo.getUserFavorites(AuthUtil.getUserId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.service.SearchService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/search")
|
||||
public class SearchController {
|
||||
|
||||
private final SearchService searchService;
|
||||
|
||||
public SearchController(SearchService searchService) {
|
||||
this.searchService = searchService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Map<String, Object>> 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,28 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.repository.StatsRepository;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/stats")
|
||||
public class StatsController {
|
||||
|
||||
private final StatsRepository repo;
|
||||
|
||||
public StatsController(StatsRepository repo) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
@PostMapping("/visit")
|
||||
public Map<String, Object> recordVisit() {
|
||||
repo.recordVisit();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@GetMapping("/visits")
|
||||
public Map<String, Object> getVisits() {
|
||||
return repo.getVisits();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.repository.VideoRepository;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.CacheService;
|
||||
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/videos")
|
||||
public class VideoController {
|
||||
|
||||
private final VideoRepository repo;
|
||||
private final CacheService cache;
|
||||
|
||||
public VideoController(VideoRepository repo, CacheService cache) {
|
||||
this.repo = repo;
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Map<String, Object>> list(@RequestParam(required = false) String status) {
|
||||
return repo.findAll(status);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public Map<String, Object> detail(@PathVariable String id) {
|
||||
var video = repo.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");
|
||||
}
|
||||
repo.updateTitle(id, title);
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/skip")
|
||||
public Map<String, Object> skip(@PathVariable String id) {
|
||||
AuthUtil.requireAdmin();
|
||||
repo.updateStatus(id, "skip");
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public Map<String, Object> delete(@PathVariable String id) {
|
||||
AuthUtil.requireAdmin();
|
||||
repo.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();
|
||||
repo.deleteVideoRestaurant(videoId, restaurantId);
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
// NOTE: SSE streaming endpoints (bulk-transcript, bulk-extract, remap-cuisine,
|
||||
// remap-foods, rebuild-vectors) and process/extract/fetch-transcript endpoints
|
||||
// will be added in VideoSseController after core pipeline services are migrated.
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
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.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
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 NamedParameterJdbcTemplate jdbc;
|
||||
private final PipelineService pipelineService;
|
||||
private final OciGenAiService genAi;
|
||||
private final CacheService cache;
|
||||
private final ObjectMapper mapper;
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
public VideoSseController(NamedParameterJdbcTemplate jdbc,
|
||||
PipelineService pipelineService,
|
||||
OciGenAiService genAi,
|
||||
CacheService cache,
|
||||
ObjectMapper mapper) {
|
||||
this.jdbc = jdbc;
|
||||
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 {
|
||||
String sql = """
|
||||
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
|
||||
""";
|
||||
var rows = jdbc.query(sql, new MapSqlParameterSource(), (rs, rowNum) -> {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", rs.getString("ID"));
|
||||
m.put("video_id", rs.getString("VIDEO_ID"));
|
||||
m.put("title", rs.getString("TITLE"));
|
||||
m.put("url", rs.getString("URL"));
|
||||
m.put("transcript", JsonUtil.readClob(rs.getObject("TRANSCRIPT_TEXT")));
|
||||
return m;
|
||||
});
|
||||
|
||||
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 {
|
||||
String 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
|
||||
""";
|
||||
var rows = jdbc.query(sql, new MapSqlParameterSource(), (rs, rowNum) -> {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", rs.getString("ID"));
|
||||
m.put("name", rs.getString("NAME"));
|
||||
m.put("cuisine_type", rs.getString("CUISINE_TYPE"));
|
||||
m.put("foods_mentioned", JsonUtil.readClob(rs.getObject("FOODS")));
|
||||
return m;
|
||||
});
|
||||
|
||||
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
|
||||
if (!allMissed.isEmpty()) {
|
||||
emit(emitter, Map.of("type", "retry", "missed", allMissed.size()));
|
||||
for (int i = 0; i < allMissed.size(); i += 10) {
|
||||
var batch = allMissed.subList(i, Math.min(i + 10, allMissed.size()));
|
||||
try {
|
||||
var result = applyRemapBatch(batch);
|
||||
updated += result.updated;
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
cache.flush();
|
||||
emit(emitter, Map.of("type", "complete", "total", total, "updated", updated));
|
||||
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 {
|
||||
String 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
|
||||
""";
|
||||
var rows = jdbc.query(sql, new MapSqlParameterSource(), (rs, rowNum) -> {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", rs.getString("ID"));
|
||||
m.put("name", rs.getString("NAME"));
|
||||
m.put("cuisine_type", rs.getString("CUISINE_TYPE"));
|
||||
m.put("foods", JsonUtil.parseStringList(rs.getObject("FOODS_MENTIONED")));
|
||||
m.put("video_title", rs.getString("TITLE"));
|
||||
return m;
|
||||
});
|
||||
|
||||
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);
|
||||
emit(emitter, Map.of("type", "error", "message", e.getMessage(), "current", i));
|
||||
}
|
||||
}
|
||||
|
||||
if (!allMissed.isEmpty()) {
|
||||
emit(emitter, Map.of("type", "retry", "missed", allMissed.size()));
|
||||
for (int i = 0; i < allMissed.size(); i += 10) {
|
||||
var batch = allMissed.subList(i, Math.min(i + 10, allMissed.size()));
|
||||
try {
|
||||
var r = applyFoodsBatch(batch);
|
||||
updated += r.updated;
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
cache.flush();
|
||||
emit(emitter, Map.of("type", "complete", "total", total, "updated", updated));
|
||||
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;
|
||||
}
|
||||
jdbc.update("UPDATE restaurants SET cuisine_type = :ct WHERE id = :id",
|
||||
new MapSqlParameterSource().addValue("ct", newType).addValue("id", id));
|
||||
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;
|
||||
}
|
||||
jdbc.update("UPDATE video_restaurants SET foods_mentioned = :foods WHERE id = :id",
|
||||
new MapSqlParameterSource()
|
||||
.addValue("foods", mapper.writeValueAsString(newFoods))
|
||||
.addValue("id", id));
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.tasteby.repository;
|
||||
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.util.*;
|
||||
|
||||
@Repository
|
||||
public class ChannelRepository {
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
|
||||
public ChannelRepository(NamedParameterJdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findAllActive() {
|
||||
String sql = """
|
||||
SELECT c.id, c.channel_id, c.channel_name, c.title_filter, c.created_at,
|
||||
(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
|
||||
""";
|
||||
return jdbc.query(sql, new MapSqlParameterSource(), (rs, rowNum) -> {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", rs.getString("ID"));
|
||||
m.put("channel_id", rs.getString("CHANNEL_ID"));
|
||||
m.put("channel_name", rs.getString("CHANNEL_NAME"));
|
||||
m.put("title_filter", rs.getString("TITLE_FILTER"));
|
||||
Timestamp ts = rs.getTimestamp("CREATED_AT");
|
||||
m.put("created_at", ts != null ? ts.toInstant().toString() : null);
|
||||
m.put("video_count", rs.getInt("VIDEO_COUNT"));
|
||||
Timestamp lastVideo = rs.getTimestamp("LAST_VIDEO_AT");
|
||||
m.put("last_video_at", lastVideo != null ? lastVideo.toInstant().toString() : null);
|
||||
return m;
|
||||
});
|
||||
}
|
||||
|
||||
public String create(String channelId, String channelName, String titleFilter) {
|
||||
String id = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase();
|
||||
String sql = """
|
||||
INSERT INTO channels (id, channel_id, channel_name, title_filter)
|
||||
VALUES (:id, :cid, :cname, :tf)
|
||||
""";
|
||||
var params = new MapSqlParameterSource();
|
||||
params.addValue("id", id);
|
||||
params.addValue("cid", channelId);
|
||||
params.addValue("cname", channelName);
|
||||
params.addValue("tf", titleFilter);
|
||||
jdbc.update(sql, params);
|
||||
return id;
|
||||
}
|
||||
|
||||
public boolean deactivate(String channelId) {
|
||||
String sql = "UPDATE channels SET is_active = 0 WHERE channel_id = :cid AND is_active = 1";
|
||||
int count = jdbc.update(sql, new MapSqlParameterSource("cid", channelId));
|
||||
if (count == 0) {
|
||||
// Try by DB id
|
||||
sql = "UPDATE channels SET is_active = 0 WHERE id = :cid AND is_active = 1";
|
||||
count = jdbc.update(sql, new MapSqlParameterSource("cid", channelId));
|
||||
}
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
public Map<String, Object> findByChannelId(String channelId) {
|
||||
String sql = "SELECT id, channel_id, channel_name, title_filter FROM channels WHERE channel_id = :cid AND is_active = 1";
|
||||
var rows = jdbc.queryForList(sql, new MapSqlParameterSource("cid", channelId));
|
||||
return rows.isEmpty() ? null : rows.getFirst();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package com.tasteby.repository;
|
||||
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import com.tasteby.util.RegionParser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.*;
|
||||
|
||||
@Repository
|
||||
public class RestaurantRepository {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
private final NamedParameterJdbcTemplate namedJdbc;
|
||||
|
||||
public RestaurantRepository(JdbcTemplate jdbc, NamedParameterJdbcTemplate namedJdbc) {
|
||||
this.jdbc = jdbc;
|
||||
this.namedJdbc = namedJdbc;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findAll(int limit, int offset,
|
||||
String cuisine, String region, String channel) {
|
||||
var conditions = new ArrayList<String>();
|
||||
conditions.add("r.latitude IS NOT NULL");
|
||||
conditions.add("EXISTS (SELECT 1 FROM video_restaurants vr0 WHERE vr0.restaurant_id = r.id)");
|
||||
|
||||
var params = new MapSqlParameterSource();
|
||||
params.addValue("lim", limit);
|
||||
params.addValue("off", offset);
|
||||
|
||||
String joinClause = "";
|
||||
if (cuisine != null && !cuisine.isBlank()) {
|
||||
conditions.add("r.cuisine_type = :cuisine");
|
||||
params.addValue("cuisine", cuisine);
|
||||
}
|
||||
if (region != null && !region.isBlank()) {
|
||||
conditions.add("r.region LIKE :region");
|
||||
params.addValue("region", "%" + region + "%");
|
||||
}
|
||||
if (channel != null && !channel.isBlank()) {
|
||||
joinClause = """
|
||||
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
|
||||
""";
|
||||
conditions.add("c_f.channel_name = :channel");
|
||||
params.addValue("channel", channel);
|
||||
}
|
||||
|
||||
String where = String.join(" AND ", conditions);
|
||||
String sql = """
|
||||
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
|
||||
%s
|
||||
WHERE %s
|
||||
ORDER BY r.updated_at DESC
|
||||
OFFSET :off ROWS FETCH NEXT :lim ROWS ONLY
|
||||
""".formatted(joinClause, where);
|
||||
|
||||
List<Map<String, Object>> rows = namedJdbc.queryForList(sql, params);
|
||||
|
||||
if (!rows.isEmpty()) {
|
||||
attachChannels(rows);
|
||||
attachFoodsMentioned(rows);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
public Map<String, Object> findById(String id) {
|
||||
String sql = """
|
||||
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
|
||||
""";
|
||||
var params = new MapSqlParameterSource("id", id);
|
||||
List<Map<String, Object>> rows = namedJdbc.queryForList(sql, params);
|
||||
return rows.isEmpty() ? null : normalizeRow(rows.getFirst());
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findVideoLinks(String restaurantId) {
|
||||
String sql = """
|
||||
SELECT v.video_id, v.title, v.url, v.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 = :rid
|
||||
ORDER BY v.published_at DESC
|
||||
""";
|
||||
var params = new MapSqlParameterSource("rid", restaurantId);
|
||||
return namedJdbc.query(sql, params, (rs, rowNum) -> {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("video_id", rs.getString("video_id"));
|
||||
m.put("title", rs.getString("title"));
|
||||
m.put("url", rs.getString("url"));
|
||||
Timestamp ts = rs.getTimestamp("published_at");
|
||||
m.put("published_at", ts != null ? ts.toInstant().toString() : null);
|
||||
m.put("foods_mentioned", JsonUtil.parseStringList(rs.getObject("foods_mentioned")));
|
||||
m.put("evaluation", JsonUtil.parseMap(rs.getObject("evaluation")));
|
||||
m.put("guests", JsonUtil.parseStringList(rs.getObject("guests")));
|
||||
m.put("channel_name", rs.getString("channel_name"));
|
||||
m.put("channel_id", rs.getString("channel_id"));
|
||||
return m;
|
||||
});
|
||||
}
|
||||
|
||||
public String upsert(Map<String, Object> data) {
|
||||
String name = (String) data.get("name");
|
||||
String address = (String) data.get("address");
|
||||
String region = (String) data.get("region");
|
||||
|
||||
if (region == null && address != null) {
|
||||
region = RegionParser.parse(address);
|
||||
}
|
||||
|
||||
// Try find by google_place_id then by name
|
||||
String existing = null;
|
||||
String gid = (String) data.get("google_place_id");
|
||||
if (gid != null) {
|
||||
existing = findIdByPlaceId(gid);
|
||||
}
|
||||
if (existing == null) {
|
||||
existing = findIdByName(name);
|
||||
}
|
||||
|
||||
if (existing != null) {
|
||||
String sql = """
|
||||
UPDATE restaurants SET
|
||||
name = :name,
|
||||
address = COALESCE(:addr, address),
|
||||
region = COALESCE(:reg, region),
|
||||
latitude = COALESCE(:lat, latitude),
|
||||
longitude = COALESCE(:lng, longitude),
|
||||
cuisine_type = COALESCE(:cuisine, cuisine_type),
|
||||
price_range = COALESCE(:price, price_range),
|
||||
google_place_id = COALESCE(:gid, google_place_id),
|
||||
phone = COALESCE(:phone, phone),
|
||||
website = COALESCE(:web, website),
|
||||
business_status = COALESCE(:bstatus, business_status),
|
||||
rating = COALESCE(:rating, rating),
|
||||
rating_count = COALESCE(:rcnt, rating_count),
|
||||
updated_at = SYSTIMESTAMP
|
||||
WHERE id = :id
|
||||
""";
|
||||
var params = buildUpsertParams(data, name, address, region);
|
||||
params.addValue("id", existing);
|
||||
namedJdbc.update(sql, params);
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Insert
|
||||
String newId = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase();
|
||||
String sql = """
|
||||
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, :addr, :reg, :lat, :lng, :cuisine, :price, :gid,
|
||||
:phone, :web, :bstatus, :rating, :rcnt)
|
||||
""";
|
||||
var params = buildUpsertParams(data, name, address, region);
|
||||
params.addValue("id", newId);
|
||||
namedJdbc.update(sql, params);
|
||||
return newId;
|
||||
}
|
||||
|
||||
public void update(String id, Map<String, Object> fields) {
|
||||
var sets = new ArrayList<String>();
|
||||
var params = new MapSqlParameterSource("rid", id);
|
||||
|
||||
List<String> allowed = List.of("name", "address", "region", "cuisine_type",
|
||||
"price_range", "phone", "website", "latitude", "longitude");
|
||||
for (String field : allowed) {
|
||||
if (fields.containsKey(field)) {
|
||||
sets.add(field + " = :" + field);
|
||||
params.addValue(field, fields.get(field));
|
||||
}
|
||||
}
|
||||
if (sets.isEmpty()) return;
|
||||
sets.add("updated_at = SYSTIMESTAMP");
|
||||
|
||||
String sql = "UPDATE restaurants SET " + String.join(", ", sets) + " WHERE id = :rid";
|
||||
namedJdbc.update(sql, params);
|
||||
}
|
||||
|
||||
public void delete(String id) {
|
||||
var params = new MapSqlParameterSource("rid", id);
|
||||
namedJdbc.update("DELETE FROM restaurant_vectors WHERE restaurant_id = :rid", params);
|
||||
namedJdbc.update("DELETE FROM user_reviews WHERE restaurant_id = :rid", params);
|
||||
namedJdbc.update("DELETE FROM user_favorites WHERE restaurant_id = :rid", params);
|
||||
namedJdbc.update("DELETE FROM video_restaurants WHERE restaurant_id = :rid", params);
|
||||
namedJdbc.update("DELETE FROM restaurants WHERE id = :rid", params);
|
||||
}
|
||||
|
||||
public String linkVideoRestaurant(String videoDbId, String restaurantId,
|
||||
List<String> foods, String evaluation, List<String> guests) {
|
||||
String linkId = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase();
|
||||
String sql = """
|
||||
INSERT INTO video_restaurants (id, video_id, restaurant_id, foods_mentioned, evaluation, guests)
|
||||
VALUES (:id, :vid, :rid, :foods, :eval, :guests)
|
||||
""";
|
||||
var params = new MapSqlParameterSource();
|
||||
params.addValue("id", linkId);
|
||||
params.addValue("vid", videoDbId);
|
||||
params.addValue("rid", restaurantId);
|
||||
params.addValue("foods", JsonUtil.toJson(foods != null ? foods : List.of()));
|
||||
params.addValue("eval", JsonUtil.toJson(evaluation != null ? Map.of("text", evaluation) : Map.of()));
|
||||
params.addValue("guests", JsonUtil.toJson(guests != null ? guests : List.of()));
|
||||
try {
|
||||
namedJdbc.update(sql, params);
|
||||
return linkId;
|
||||
} catch (Exception e) {
|
||||
if (e.getMessage() != null && e.getMessage().toUpperCase().contains("UQ_VR_VIDEO_REST")) {
|
||||
return null; // duplicate
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// --- private helpers ---
|
||||
|
||||
private String findIdByPlaceId(String placeId) {
|
||||
var rows = namedJdbc.queryForList(
|
||||
"SELECT id FROM restaurants WHERE google_place_id = :gid",
|
||||
new MapSqlParameterSource("gid", placeId));
|
||||
return rows.isEmpty() ? null : (String) rows.getFirst().get("ID");
|
||||
}
|
||||
|
||||
private String findIdByName(String name) {
|
||||
var rows = namedJdbc.queryForList(
|
||||
"SELECT id FROM restaurants WHERE name = :n",
|
||||
new MapSqlParameterSource("n", name));
|
||||
return rows.isEmpty() ? null : (String) rows.getFirst().get("ID");
|
||||
}
|
||||
|
||||
private MapSqlParameterSource buildUpsertParams(Map<String, Object> data,
|
||||
String name, String address, String region) {
|
||||
var params = new MapSqlParameterSource();
|
||||
params.addValue("name", name);
|
||||
params.addValue("addr", address);
|
||||
params.addValue("reg", truncateBytes(region, 100));
|
||||
params.addValue("lat", data.get("latitude"));
|
||||
params.addValue("lng", data.get("longitude"));
|
||||
params.addValue("cuisine", truncateBytes((String) data.get("cuisine_type"), 100));
|
||||
params.addValue("price", truncateBytes((String) data.get("price_range"), 50));
|
||||
params.addValue("gid", data.get("google_place_id"));
|
||||
params.addValue("phone", data.get("phone"));
|
||||
params.addValue("web", truncateBytes((String) data.get("website"), 500));
|
||||
params.addValue("bstatus", data.get("business_status"));
|
||||
params.addValue("rating", data.get("rating"));
|
||||
params.addValue("rcnt", data.get("rating_count"));
|
||||
return params;
|
||||
}
|
||||
|
||||
private String truncateBytes(String val, int maxBytes) {
|
||||
if (val == null) return null;
|
||||
byte[] bytes = val.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
if (bytes.length <= maxBytes) return val;
|
||||
return new String(bytes, 0, maxBytes, java.nio.charset.StandardCharsets.UTF_8).trim();
|
||||
}
|
||||
|
||||
private void attachChannels(List<Map<String, Object>> rows) {
|
||||
List<String> ids = rows.stream().map(r -> (String) r.get("ID")).filter(Objects::nonNull).toList();
|
||||
if (ids.isEmpty()) return;
|
||||
|
||||
var params = new MapSqlParameterSource();
|
||||
var placeholders = new ArrayList<String>();
|
||||
for (int i = 0; i < ids.size(); i++) {
|
||||
placeholders.add(":id" + i);
|
||||
params.addValue("id" + i, ids.get(i));
|
||||
}
|
||||
String sql = """
|
||||
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 (%s)
|
||||
""".formatted(String.join(", ", placeholders));
|
||||
|
||||
Map<String, List<String>> chMap = new HashMap<>();
|
||||
namedJdbc.query(sql, params, (rs) -> {
|
||||
chMap.computeIfAbsent(rs.getString("RESTAURANT_ID"), k -> new ArrayList<>())
|
||||
.add(rs.getString("CHANNEL_NAME"));
|
||||
});
|
||||
for (var r : rows) {
|
||||
String id = (String) r.get("ID");
|
||||
r.put("channels", chMap.getOrDefault(id, List.of()));
|
||||
}
|
||||
}
|
||||
|
||||
private void attachFoodsMentioned(List<Map<String, Object>> rows) {
|
||||
List<String> ids = rows.stream().map(r -> (String) r.get("ID")).filter(Objects::nonNull).toList();
|
||||
if (ids.isEmpty()) return;
|
||||
|
||||
var params = new MapSqlParameterSource();
|
||||
var placeholders = new ArrayList<String>();
|
||||
for (int i = 0; i < ids.size(); i++) {
|
||||
placeholders.add(":id" + i);
|
||||
params.addValue("id" + i, ids.get(i));
|
||||
}
|
||||
String sql = """
|
||||
SELECT vr.restaurant_id, vr.foods_mentioned
|
||||
FROM video_restaurants vr
|
||||
WHERE vr.restaurant_id IN (%s)
|
||||
""".formatted(String.join(", ", placeholders));
|
||||
|
||||
Map<String, List<String>> foodsMap = new HashMap<>();
|
||||
namedJdbc.query(sql, params, (rs) -> {
|
||||
String rid = rs.getString("RESTAURANT_ID");
|
||||
List<String> foods = JsonUtil.parseStringList(rs.getObject("FOODS_MENTIONED"));
|
||||
for (String f : foods) {
|
||||
foodsMap.computeIfAbsent(rid, k -> new ArrayList<>());
|
||||
if (!foodsMap.get(rid).contains(f)) {
|
||||
foodsMap.get(rid).add(f);
|
||||
}
|
||||
}
|
||||
});
|
||||
for (var r : rows) {
|
||||
String id = (String) r.get("ID");
|
||||
List<String> all = foodsMap.getOrDefault(id, List.of());
|
||||
r.put("foods_mentioned", all.size() > 10 ? all.subList(0, 10) : all);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> normalizeRow(Map<String, Object> row) {
|
||||
// Oracle returns uppercase keys; normalize to lowercase
|
||||
var result = new LinkedHashMap<String, Object>();
|
||||
for (var entry : row.entrySet()) {
|
||||
result.put(entry.getKey().toLowerCase(), entry.getValue());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package com.tasteby.repository;
|
||||
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.Date;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
|
||||
@Repository
|
||||
public class ReviewRepository {
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
|
||||
public ReviewRepository(NamedParameterJdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
public Map<String, Object> create(String userId, String restaurantId,
|
||||
double rating, String reviewText, LocalDate visitedAt) {
|
||||
String id = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase();
|
||||
String sql = """
|
||||
INSERT INTO user_reviews (id, user_id, restaurant_id, rating, review_text, visited_at)
|
||||
VALUES (:id, :uid, :rid, :rating, :text, :visited)
|
||||
""";
|
||||
var params = new MapSqlParameterSource();
|
||||
params.addValue("id", id);
|
||||
params.addValue("uid", userId);
|
||||
params.addValue("rid", restaurantId);
|
||||
params.addValue("rating", rating);
|
||||
params.addValue("text", reviewText);
|
||||
params.addValue("visited", visitedAt != null ? Date.valueOf(visitedAt) : null);
|
||||
jdbc.update(sql, params);
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
public Map<String, Object> update(String reviewId, String userId,
|
||||
Double rating, String reviewText, LocalDate visitedAt) {
|
||||
String sql = """
|
||||
UPDATE user_reviews SET
|
||||
rating = COALESCE(:rating, rating),
|
||||
review_text = COALESCE(:text, review_text),
|
||||
visited_at = COALESCE(:visited, visited_at),
|
||||
updated_at = SYSTIMESTAMP
|
||||
WHERE id = :id AND user_id = :uid
|
||||
""";
|
||||
var params = new MapSqlParameterSource();
|
||||
params.addValue("rating", rating);
|
||||
params.addValue("text", reviewText);
|
||||
params.addValue("visited", visitedAt != null ? Date.valueOf(visitedAt) : null);
|
||||
params.addValue("id", reviewId);
|
||||
params.addValue("uid", userId);
|
||||
int count = jdbc.update(sql, params);
|
||||
return count > 0 ? findById(reviewId) : null;
|
||||
}
|
||||
|
||||
public boolean delete(String reviewId, String userId) {
|
||||
String sql = "DELETE FROM user_reviews WHERE id = :id AND user_id = :uid";
|
||||
int count = jdbc.update(sql, new MapSqlParameterSource()
|
||||
.addValue("id", reviewId).addValue("uid", userId));
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
public Map<String, Object> findById(String reviewId) {
|
||||
String sql = """
|
||||
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
|
||||
""";
|
||||
var rows = jdbc.query(sql, new MapSqlParameterSource("id", reviewId), this::mapReviewRow);
|
||||
return rows.isEmpty() ? null : rows.getFirst();
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findByRestaurant(String restaurantId, int limit, int offset) {
|
||||
String sql = """
|
||||
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 = :rid
|
||||
ORDER BY r.created_at DESC
|
||||
OFFSET :off ROWS FETCH NEXT :lim ROWS ONLY
|
||||
""";
|
||||
var params = new MapSqlParameterSource()
|
||||
.addValue("rid", restaurantId)
|
||||
.addValue("off", offset).addValue("lim", limit);
|
||||
return jdbc.query(sql, params, this::mapReviewRow);
|
||||
}
|
||||
|
||||
public Map<String, Object> getAvgRating(String restaurantId) {
|
||||
String sql = """
|
||||
SELECT ROUND(AVG(rating), 1) AS avg_rating, COUNT(*) AS review_count
|
||||
FROM user_reviews WHERE restaurant_id = :rid
|
||||
""";
|
||||
var row = jdbc.queryForMap(sql, new MapSqlParameterSource("rid", restaurantId));
|
||||
return Map.of(
|
||||
"avg_rating", row.get("AVG_RATING") != null ? ((Number) row.get("AVG_RATING")).doubleValue() : null,
|
||||
"review_count", ((Number) row.get("REVIEW_COUNT")).intValue()
|
||||
);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findByUser(String userId, int limit, int offset) {
|
||||
String sql = """
|
||||
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 = :uid
|
||||
ORDER BY r.created_at DESC
|
||||
OFFSET :off ROWS FETCH NEXT :lim ROWS ONLY
|
||||
""";
|
||||
var params = new MapSqlParameterSource()
|
||||
.addValue("uid", userId).addValue("off", offset).addValue("lim", limit);
|
||||
return jdbc.query(sql, params, (rs, rowNum) -> {
|
||||
var m = mapReviewRow(rs, rowNum);
|
||||
m.put("restaurant_name", rs.getString("RESTAURANT_NAME"));
|
||||
return m;
|
||||
});
|
||||
}
|
||||
|
||||
// Favorites
|
||||
public boolean isFavorited(String userId, String restaurantId) {
|
||||
String sql = "SELECT COUNT(*) FROM user_favorites WHERE user_id = :u AND restaurant_id = :r";
|
||||
Integer cnt = jdbc.queryForObject(sql, new MapSqlParameterSource()
|
||||
.addValue("u", userId).addValue("r", restaurantId), Integer.class);
|
||||
return cnt != null && cnt > 0;
|
||||
}
|
||||
|
||||
public boolean toggleFavorite(String userId, String restaurantId) {
|
||||
var params = new MapSqlParameterSource().addValue("u", userId).addValue("r", restaurantId);
|
||||
String check = "SELECT id FROM user_favorites WHERE user_id = :u AND restaurant_id = :r";
|
||||
var rows = jdbc.queryForList(check, params);
|
||||
if (!rows.isEmpty()) {
|
||||
jdbc.update("DELETE FROM user_favorites WHERE user_id = :u AND restaurant_id = :r", params);
|
||||
return false; // unfavorited
|
||||
}
|
||||
String id = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase();
|
||||
params.addValue("id", id);
|
||||
jdbc.update("INSERT INTO user_favorites (id, user_id, restaurant_id) VALUES (:id, :u, :r)", params);
|
||||
return true; // favorited
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getUserFavorites(String userId) {
|
||||
String sql = """
|
||||
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 = :u ORDER BY f.created_at DESC
|
||||
""";
|
||||
return jdbc.queryForList(sql, new MapSqlParameterSource("u", userId));
|
||||
}
|
||||
|
||||
private Map<String, Object> mapReviewRow(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
||||
var m = new LinkedHashMap<String, Object>();
|
||||
m.put("id", rs.getString("ID"));
|
||||
m.put("user_id", rs.getString("USER_ID"));
|
||||
m.put("restaurant_id", rs.getString("RESTAURANT_ID"));
|
||||
m.put("rating", rs.getDouble("RATING"));
|
||||
m.put("review_text", JsonUtil.readClob(rs.getObject("REVIEW_TEXT")));
|
||||
Date visited = rs.getDate("VISITED_AT");
|
||||
m.put("visited_at", visited != null ? visited.toLocalDate().toString() : null);
|
||||
Timestamp created = rs.getTimestamp("CREATED_AT");
|
||||
m.put("created_at", created != null ? created.toInstant().toString() : null);
|
||||
Timestamp updated = rs.getTimestamp("UPDATED_AT");
|
||||
m.put("updated_at", updated != null ? updated.toInstant().toString() : null);
|
||||
m.put("user_nickname", rs.getString("NICKNAME"));
|
||||
m.put("user_avatar_url", rs.getString("AVATAR_URL"));
|
||||
return m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.tasteby.repository;
|
||||
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Repository
|
||||
public class StatsRepository {
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
|
||||
public StatsRepository(NamedParameterJdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
public void recordVisit() {
|
||||
String sql = """
|
||||
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)
|
||||
""";
|
||||
jdbc.update(sql, new MapSqlParameterSource());
|
||||
}
|
||||
|
||||
public Map<String, Object> getVisits() {
|
||||
var empty = new MapSqlParameterSource();
|
||||
Integer today = jdbc.queryForObject(
|
||||
"SELECT NVL(visit_count, 0) FROM site_visits WHERE visit_date = TRUNC(SYSDATE)",
|
||||
empty, Integer.class);
|
||||
if (today == null) today = 0;
|
||||
|
||||
Integer total = jdbc.queryForObject(
|
||||
"SELECT NVL(SUM(visit_count), 0) FROM site_visits",
|
||||
empty, Integer.class);
|
||||
if (total == null) total = 0;
|
||||
|
||||
return Map.of("today", today, "total", total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.tasteby.repository;
|
||||
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Repository
|
||||
public class UserRepository {
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
|
||||
public UserRepository(NamedParameterJdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
public Map<String, Object> findOrCreate(String provider, String providerId,
|
||||
String email, String nickname, String avatarUrl) {
|
||||
// Try find existing
|
||||
String findSql = """
|
||||
SELECT id, email, nickname, avatar_url, is_admin
|
||||
FROM tasteby_users
|
||||
WHERE provider = :provider AND provider_id = :pid
|
||||
""";
|
||||
var params = new MapSqlParameterSource()
|
||||
.addValue("provider", provider)
|
||||
.addValue("pid", providerId);
|
||||
var rows = jdbc.queryForList(findSql, params);
|
||||
|
||||
if (!rows.isEmpty()) {
|
||||
// Update last_login_at
|
||||
var row = rows.getFirst();
|
||||
String userId = (String) row.get("ID");
|
||||
jdbc.update("UPDATE tasteby_users SET last_login_at = SYSTIMESTAMP WHERE id = :id",
|
||||
new MapSqlParameterSource("id", userId));
|
||||
return Map.of(
|
||||
"id", userId,
|
||||
"email", row.getOrDefault("EMAIL", ""),
|
||||
"nickname", row.getOrDefault("NICKNAME", ""),
|
||||
"avatar_url", row.getOrDefault("AVATAR_URL", ""),
|
||||
"is_admin", row.get("IS_ADMIN") != null && ((Number) row.get("IS_ADMIN")).intValue() == 1
|
||||
);
|
||||
}
|
||||
|
||||
// Create new user
|
||||
String id = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase();
|
||||
String insertSql = """
|
||||
INSERT INTO tasteby_users (id, provider, provider_id, email, nickname, avatar_url)
|
||||
VALUES (:id, :provider, :pid, :email, :nick, :avatar)
|
||||
""";
|
||||
params.addValue("id", id);
|
||||
params.addValue("email", email);
|
||||
params.addValue("nick", nickname);
|
||||
params.addValue("avatar", avatarUrl);
|
||||
jdbc.update(insertSql, params);
|
||||
|
||||
return Map.of(
|
||||
"id", id,
|
||||
"email", email != null ? email : "",
|
||||
"nickname", nickname != null ? nickname : "",
|
||||
"avatar_url", avatarUrl != null ? avatarUrl : "",
|
||||
"is_admin", false
|
||||
);
|
||||
}
|
||||
|
||||
public Map<String, Object> findById(String userId) {
|
||||
String sql = "SELECT id, email, nickname, avatar_url, is_admin FROM tasteby_users WHERE id = :id";
|
||||
var rows = jdbc.queryForList(sql, new MapSqlParameterSource("id", userId));
|
||||
if (rows.isEmpty()) return null;
|
||||
var row = rows.getFirst();
|
||||
return Map.of(
|
||||
"id", row.get("ID"),
|
||||
"email", row.getOrDefault("EMAIL", ""),
|
||||
"nickname", row.getOrDefault("NICKNAME", ""),
|
||||
"avatar_url", row.getOrDefault("AVATAR_URL", ""),
|
||||
"is_admin", row.get("IS_ADMIN") != null && ((Number) row.get("IS_ADMIN")).intValue() == 1
|
||||
);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findAllWithCounts(int limit, int offset) {
|
||||
String sql = """
|
||||
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 :off ROWS FETCH NEXT :lim ROWS ONLY
|
||||
""";
|
||||
var params = new MapSqlParameterSource().addValue("off", offset).addValue("lim", limit);
|
||||
return jdbc.queryForList(sql, params);
|
||||
}
|
||||
|
||||
public int countAll() {
|
||||
var result = jdbc.queryForObject("SELECT COUNT(*) FROM tasteby_users",
|
||||
new MapSqlParameterSource(), Integer.class);
|
||||
return result != null ? result : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.tasteby.repository;
|
||||
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.util.*;
|
||||
|
||||
@Repository
|
||||
public class VideoRepository {
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
|
||||
public VideoRepository(NamedParameterJdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findAll(String status) {
|
||||
var params = new MapSqlParameterSource();
|
||||
String where = "";
|
||||
if (status != null && !status.isBlank()) {
|
||||
where = "WHERE v.status = :st";
|
||||
params.addValue("st", status);
|
||||
}
|
||||
|
||||
String sql = """
|
||||
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
|
||||
%s
|
||||
ORDER BY v.published_at DESC NULLS LAST
|
||||
""".formatted(where);
|
||||
|
||||
return jdbc.query(sql, params, (rs, rowNum) -> {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", rs.getString("ID"));
|
||||
m.put("video_id", rs.getString("VIDEO_ID"));
|
||||
m.put("title", rs.getString("TITLE"));
|
||||
m.put("url", rs.getString("URL"));
|
||||
m.put("status", rs.getString("STATUS"));
|
||||
Timestamp ts = rs.getTimestamp("PUBLISHED_AT");
|
||||
m.put("published_at", ts != null ? ts.toInstant().toString() : null);
|
||||
m.put("channel_name", rs.getString("CHANNEL_NAME"));
|
||||
m.put("has_transcript", rs.getInt("HAS_TRANSCRIPT") == 1);
|
||||
m.put("has_llm", rs.getInt("HAS_LLM") == 1);
|
||||
m.put("restaurant_count", rs.getInt("RESTAURANT_COUNT"));
|
||||
m.put("matched_count", rs.getInt("MATCHED_COUNT"));
|
||||
return m;
|
||||
});
|
||||
}
|
||||
|
||||
public Map<String, Object> findDetail(String videoDbId) {
|
||||
String sql = """
|
||||
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 = :vid
|
||||
""";
|
||||
var rows = jdbc.query(sql, new MapSqlParameterSource("vid", videoDbId), (rs, rowNum) -> {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", rs.getString("ID"));
|
||||
m.put("video_id", rs.getString("VIDEO_ID"));
|
||||
m.put("title", rs.getString("TITLE"));
|
||||
m.put("url", rs.getString("URL"));
|
||||
m.put("status", rs.getString("STATUS"));
|
||||
Timestamp ts = rs.getTimestamp("PUBLISHED_AT");
|
||||
m.put("published_at", ts != null ? ts.toInstant().toString() : null);
|
||||
m.put("transcript", JsonUtil.readClob(rs.getObject("TRANSCRIPT_TEXT")));
|
||||
m.put("channel_name", rs.getString("CHANNEL_NAME"));
|
||||
return m;
|
||||
});
|
||||
if (rows.isEmpty()) return null;
|
||||
|
||||
Map<String, Object> video = rows.getFirst();
|
||||
|
||||
// Attach extracted restaurants
|
||||
String restSql = """
|
||||
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 = :vid
|
||||
""";
|
||||
List<Map<String, Object>> restaurants = jdbc.query(restSql,
|
||||
new MapSqlParameterSource("vid", videoDbId), (rs, rowNum) -> {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("restaurant_id", rs.getString("ID"));
|
||||
m.put("name", rs.getString("NAME"));
|
||||
m.put("address", rs.getString("ADDRESS"));
|
||||
m.put("cuisine_type", rs.getString("CUISINE_TYPE"));
|
||||
m.put("price_range", rs.getString("PRICE_RANGE"));
|
||||
m.put("region", rs.getString("REGION"));
|
||||
m.put("foods_mentioned", JsonUtil.parseStringList(rs.getObject("FOODS_MENTIONED")));
|
||||
m.put("evaluation", JsonUtil.parseMap(rs.getObject("EVALUATION")));
|
||||
m.put("guests", JsonUtil.parseStringList(rs.getObject("GUESTS")));
|
||||
m.put("google_place_id", rs.getString("GOOGLE_PLACE_ID"));
|
||||
m.put("has_location", rs.getObject("LATITUDE") != null && rs.getObject("LONGITUDE") != null);
|
||||
return m;
|
||||
});
|
||||
video.put("restaurants", restaurants);
|
||||
return video;
|
||||
}
|
||||
|
||||
public void updateStatus(String videoDbId, String status) {
|
||||
jdbc.update("UPDATE videos SET status = :st WHERE id = :vid",
|
||||
new MapSqlParameterSource().addValue("st", status).addValue("vid", videoDbId));
|
||||
}
|
||||
|
||||
public void updateTitle(String videoDbId, String title) {
|
||||
jdbc.update("UPDATE videos SET title = :title WHERE id = :vid",
|
||||
new MapSqlParameterSource().addValue("title", title).addValue("vid", videoDbId));
|
||||
}
|
||||
|
||||
public void updateTranscript(String videoDbId, String transcript) {
|
||||
jdbc.update("UPDATE videos SET transcript_text = :txt WHERE id = :vid",
|
||||
new MapSqlParameterSource().addValue("txt", transcript).addValue("vid", videoDbId));
|
||||
}
|
||||
|
||||
public void delete(String videoDbId) {
|
||||
var params = new MapSqlParameterSource("vid", videoDbId);
|
||||
// Delete orphaned vectors/reviews/restaurants
|
||||
jdbc.update("""
|
||||
DELETE FROM restaurant_vectors WHERE restaurant_id IN (
|
||||
SELECT vr.restaurant_id FROM video_restaurants vr
|
||||
WHERE vr.video_id = :vid
|
||||
AND NOT EXISTS (SELECT 1 FROM video_restaurants vr2
|
||||
WHERE vr2.restaurant_id = vr.restaurant_id AND vr2.video_id != :vid)
|
||||
)""", params);
|
||||
jdbc.update("""
|
||||
DELETE FROM user_reviews WHERE restaurant_id IN (
|
||||
SELECT vr.restaurant_id FROM video_restaurants vr
|
||||
WHERE vr.video_id = :vid
|
||||
AND NOT EXISTS (SELECT 1 FROM video_restaurants vr2
|
||||
WHERE vr2.restaurant_id = vr.restaurant_id AND vr2.video_id != :vid)
|
||||
)""", params);
|
||||
jdbc.update("""
|
||||
DELETE FROM restaurants WHERE id IN (
|
||||
SELECT vr.restaurant_id FROM video_restaurants vr
|
||||
WHERE vr.video_id = :vid
|
||||
AND NOT EXISTS (SELECT 1 FROM video_restaurants vr2
|
||||
WHERE vr2.restaurant_id = vr.restaurant_id AND vr2.video_id != :vid)
|
||||
)""", params);
|
||||
jdbc.update("DELETE FROM video_restaurants WHERE video_id = :vid", params);
|
||||
jdbc.update("DELETE FROM videos WHERE id = :vid", params);
|
||||
}
|
||||
|
||||
public void deleteVideoRestaurant(String videoDbId, String restaurantId) {
|
||||
var params = new MapSqlParameterSource().addValue("vid", videoDbId).addValue("rid", restaurantId);
|
||||
jdbc.update("DELETE FROM video_restaurants WHERE video_id = :vid AND restaurant_id = :rid", params);
|
||||
// Clean up orphan
|
||||
var ridParams = new MapSqlParameterSource("rid", restaurantId);
|
||||
jdbc.update("""
|
||||
DELETE FROM restaurant_vectors WHERE restaurant_id = :rid
|
||||
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = :rid)
|
||||
""", ridParams);
|
||||
jdbc.update("""
|
||||
DELETE FROM user_reviews WHERE restaurant_id = :rid
|
||||
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = :rid)
|
||||
""", ridParams);
|
||||
jdbc.update("""
|
||||
DELETE FROM restaurants WHERE id = :rid
|
||||
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = :rid)
|
||||
""", ridParams);
|
||||
}
|
||||
|
||||
public int saveVideosBatch(String dbChannelId, List<Map<String, Object>> videos) {
|
||||
if (videos.isEmpty()) return 0;
|
||||
int count = 0;
|
||||
for (var v : videos) {
|
||||
String id = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase();
|
||||
String sql = """
|
||||
INSERT INTO videos (id, channel_id, video_id, title, url, published_at)
|
||||
VALUES (:id, :cid, :vid, :title, :url, :pub)
|
||||
""";
|
||||
var params = new MapSqlParameterSource();
|
||||
params.addValue("id", id);
|
||||
params.addValue("cid", dbChannelId);
|
||||
params.addValue("vid", v.get("video_id"));
|
||||
params.addValue("title", v.get("title"));
|
||||
params.addValue("url", v.get("url"));
|
||||
params.addValue("pub", v.get("published_at"));
|
||||
try {
|
||||
jdbc.update(sql, params);
|
||||
count++;
|
||||
} catch (Exception e) {
|
||||
// duplicate video_id — skip
|
||||
if (e.getMessage() == null || !e.getMessage().toUpperCase().contains("UQ_VIDEOS_VID")) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public Set<String> getExistingVideoIds(String dbChannelId) {
|
||||
String sql = "SELECT video_id FROM videos WHERE channel_id = :cid";
|
||||
List<String> ids = jdbc.queryForList(sql,
|
||||
new MapSqlParameterSource("cid", dbChannelId), String.class);
|
||||
return new HashSet<>(ids);
|
||||
}
|
||||
|
||||
public String getLatestVideoDate(String dbChannelId) {
|
||||
String sql = "SELECT MAX(published_at) FROM videos WHERE channel_id = :cid";
|
||||
var rows = jdbc.queryForList(sql, new MapSqlParameterSource("cid", dbChannelId));
|
||||
if (rows.isEmpty() || rows.getFirst().values().iterator().next() == null) return null;
|
||||
Object val = rows.getFirst().values().iterator().next();
|
||||
if (val instanceof Timestamp ts) return ts.toInstant().toString();
|
||||
return val.toString();
|
||||
}
|
||||
}
|
||||
@@ -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,64 @@
|
||||
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.repository.UserRepository;
|
||||
import com.tasteby.security.JwtTokenProvider;
|
||||
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 UserRepository userRepo;
|
||||
private final JwtTokenProvider jwtProvider;
|
||||
private final GoogleIdTokenVerifier verifier;
|
||||
|
||||
public AuthService(UserRepository userRepo, JwtTokenProvider jwtProvider) {
|
||||
this.userRepo = userRepo;
|
||||
this.jwtProvider = jwtProvider;
|
||||
this.verifier = new GoogleIdTokenVerifier.Builder(
|
||||
new NetHttpTransport(), GsonFactory.getDefaultInstance())
|
||||
.setAudience(Collections.emptyList()) // Accept any audience
|
||||
.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();
|
||||
|
||||
Map<String, Object> user = userRepo.findOrCreate(
|
||||
"google",
|
||||
payload.getSubject(),
|
||||
payload.getEmail(),
|
||||
(String) payload.get("name"),
|
||||
(String) payload.get("picture")
|
||||
);
|
||||
|
||||
String accessToken = jwtProvider.createToken(user);
|
||||
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 Map<String, Object> getCurrentUser(String userId) {
|
||||
Map<String, Object> user = userRepo.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,115 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
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.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 NamedParameterJdbcTemplate jdbc;
|
||||
private final YouTubeService youTubeService;
|
||||
private final PipelineService pipelineService;
|
||||
private final CacheService cacheService;
|
||||
|
||||
public DaemonScheduler(NamedParameterJdbcTemplate jdbc,
|
||||
YouTubeService youTubeService,
|
||||
PipelineService pipelineService,
|
||||
CacheService cacheService) {
|
||||
this.jdbc = jdbc;
|
||||
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;
|
||||
|
||||
// Channel scanning
|
||||
if (config.scanEnabled) {
|
||||
Instant lastScan = config.lastScanAt;
|
||||
if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.scanIntervalMin, ChronoUnit.MINUTES))) {
|
||||
log.info("Running scheduled channel scan...");
|
||||
int newVideos = youTubeService.scanAllChannels();
|
||||
updateLastScan();
|
||||
if (newVideos > 0) {
|
||||
cacheService.flush();
|
||||
log.info("Scan completed: {} new videos", newVideos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Video processing
|
||||
if (config.processEnabled) {
|
||||
Instant lastProcess = config.lastProcessAt;
|
||||
if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.processIntervalMin, ChronoUnit.MINUTES))) {
|
||||
log.info("Running scheduled video processing (limit={})...", config.processLimit);
|
||||
int restaurants = pipelineService.processPending(config.processLimit);
|
||||
updateLastProcess();
|
||||
if (restaurants > 0) {
|
||||
cacheService.flush();
|
||||
log.info("Processing completed: {} restaurants extracted", restaurants);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Daemon scheduler error: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private record DaemonConfig(
|
||||
boolean scanEnabled, int scanIntervalMin,
|
||||
boolean processEnabled, int processIntervalMin, int processLimit,
|
||||
Instant lastScanAt, Instant lastProcessAt) {}
|
||||
|
||||
private DaemonConfig getConfig() {
|
||||
try {
|
||||
var rows = jdbc.queryForList(
|
||||
"SELECT * FROM daemon_config WHERE id = 1",
|
||||
new MapSqlParameterSource());
|
||||
if (rows.isEmpty()) return null;
|
||||
var row = rows.getFirst();
|
||||
return new DaemonConfig(
|
||||
toInt(row.get("SCAN_ENABLED")) == 1,
|
||||
toInt(row.get("SCAN_INTERVAL_MIN")),
|
||||
toInt(row.get("PROCESS_ENABLED")) == 1,
|
||||
toInt(row.get("PROCESS_INTERVAL_MIN")),
|
||||
toInt(row.get("PROCESS_LIMIT")),
|
||||
row.get("LAST_SCAN_AT") instanceof java.sql.Timestamp ts ? ts.toInstant() : null,
|
||||
row.get("LAST_PROCESS_AT") instanceof java.sql.Timestamp ts ? ts.toInstant() : null
|
||||
);
|
||||
} catch (Exception e) {
|
||||
log.debug("Cannot read daemon config: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateLastScan() {
|
||||
jdbc.update("UPDATE daemon_config SET last_scan_at = SYSTIMESTAMP WHERE id = 1",
|
||||
new MapSqlParameterSource());
|
||||
}
|
||||
|
||||
private void updateLastProcess() {
|
||||
jdbc.update("UPDATE daemon_config SET last_process_at = SYSTIMESTAMP WHERE id = 1",
|
||||
new MapSqlParameterSource());
|
||||
}
|
||||
|
||||
private int toInt(Object val) {
|
||||
if (val == null) return 0;
|
||||
return ((Number) val).intValue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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 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,198 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.repository.RestaurantRepository;
|
||||
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.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 NamedParameterJdbcTemplate jdbc;
|
||||
private final YouTubeService youTubeService;
|
||||
private final ExtractorService extractorService;
|
||||
private final GeocodingService geocodingService;
|
||||
private final RestaurantRepository restaurantRepo;
|
||||
private final VectorService vectorService;
|
||||
private final CacheService cacheService;
|
||||
|
||||
public PipelineService(NamedParameterJdbcTemplate jdbc,
|
||||
YouTubeService youTubeService,
|
||||
ExtractorService extractorService,
|
||||
GeocodingService geocodingService,
|
||||
RestaurantRepository restaurantRepo,
|
||||
VectorService vectorService,
|
||||
CacheService cacheService) {
|
||||
this.jdbc = jdbc;
|
||||
this.youTubeService = youTubeService;
|
||||
this.extractorService = extractorService;
|
||||
this.geocodingService = geocodingService;
|
||||
this.restaurantRepo = restaurantRepo;
|
||||
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 = restaurantRepo.upsert(data);
|
||||
|
||||
// Link video <-> restaurant
|
||||
var foods = restData.get("foods_mentioned");
|
||||
var evaluation = restData.get("evaluation");
|
||||
var guests = restData.get("guests");
|
||||
restaurantRepo.linkVideoRestaurant(
|
||||
videoDbId, restId,
|
||||
foods instanceof List<?> ? (List<String>) foods : null,
|
||||
evaluation instanceof String ? (String) evaluation : null,
|
||||
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) {
|
||||
String sql = """
|
||||
SELECT id, video_id, title, url FROM videos
|
||||
WHERE status = 'pending' ORDER BY created_at
|
||||
FETCH FIRST :n ROWS ONLY
|
||||
""";
|
||||
var videos = jdbc.queryForList(sql, new MapSqlParameterSource("n", limit));
|
||||
if (videos.isEmpty()) {
|
||||
log.info("No pending videos");
|
||||
return 0;
|
||||
}
|
||||
int total = 0;
|
||||
for (var v : videos) {
|
||||
// Normalize Oracle uppercase keys
|
||||
var normalized = normalizeKeys(v);
|
||||
total += processVideo(normalized);
|
||||
}
|
||||
if (total > 0) cacheService.flush();
|
||||
return total;
|
||||
}
|
||||
|
||||
private void updateVideoStatus(String videoDbId, String status, String transcript, String llmRaw) {
|
||||
var sets = new java.util.ArrayList<>(List.of("status = :st", "processed_at = SYSTIMESTAMP"));
|
||||
var params = new MapSqlParameterSource().addValue("st", status).addValue("vid", videoDbId);
|
||||
if (transcript != null) {
|
||||
sets.add("transcript_text = :txt");
|
||||
params.addValue("txt", transcript);
|
||||
}
|
||||
if (llmRaw != null) {
|
||||
sets.add("llm_raw_response = :llm");
|
||||
params.addValue("llm", llmRaw);
|
||||
}
|
||||
String sql = "UPDATE videos SET " + String.join(", ", sets) + " WHERE id = :vid";
|
||||
jdbc.update(sql, params);
|
||||
}
|
||||
|
||||
private Map<String, Object> normalizeKeys(Map<String, Object> row) {
|
||||
var result = new HashMap<String, Object>();
|
||||
for (var entry : row.entrySet()) {
|
||||
result.put(entry.getKey().toLowerCase(), entry.getValue());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.repository.RestaurantRepository;
|
||||
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 SearchService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SearchService.class);
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
private final RestaurantRepository restaurantRepo;
|
||||
private final VectorService vectorService;
|
||||
private final CacheService cache;
|
||||
|
||||
public SearchService(NamedParameterJdbcTemplate jdbc,
|
||||
RestaurantRepository restaurantRepo,
|
||||
VectorService vectorService,
|
||||
CacheService cache) {
|
||||
this.jdbc = jdbc;
|
||||
this.restaurantRepo = restaurantRepo;
|
||||
this.vectorService = vectorService;
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> 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) {
|
||||
// Deserialize from cache
|
||||
try {
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
return mapper.readValue(cached, new com.fasterxml.jackson.core.type.TypeReference<>() {});
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
List<Map<String, Object>> 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<Map<String, Object>>();
|
||||
for (var r : kw) {
|
||||
String id = (String) r.get("ID");
|
||||
if (id == null) id = (String) r.get("id");
|
||||
if (seen.add(id)) merged.add(r);
|
||||
}
|
||||
for (var r : sem) {
|
||||
String id = (String) r.get("ID");
|
||||
if (id == null) id = (String) r.get("id");
|
||||
if (seen.add(id)) 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<Map<String, Object>> keywordSearch(String q, int limit) {
|
||||
String pattern = "%" + q + "%";
|
||||
String sql = """
|
||||
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(: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
|
||||
""";
|
||||
var params = new MapSqlParameterSource().addValue("q", pattern).addValue("lim", limit);
|
||||
List<Map<String, Object>> rows = jdbc.queryForList(sql, params);
|
||||
if (!rows.isEmpty()) {
|
||||
attachChannels(rows);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> semanticSearch(String q, int limit) {
|
||||
try {
|
||||
var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), 0.57);
|
||||
if (similar.isEmpty()) return List.of();
|
||||
|
||||
// Deduplicate by restaurant_id, preserving distance order
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
for (var s : similar) {
|
||||
seen.add((String) s.get("restaurant_id"));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
for (String rid : seen) {
|
||||
if (results.size() >= limit) break;
|
||||
var r = restaurantRepo.findById(rid);
|
||||
if (r != null && r.get("latitude") != null) {
|
||||
results.add(r);
|
||||
}
|
||||
}
|
||||
|
||||
if (!results.isEmpty()) attachChannels(results);
|
||||
return results;
|
||||
} catch (Exception e) {
|
||||
log.warn("Semantic search failed, falling back to keyword: {}", e.getMessage());
|
||||
return keywordSearch(q, limit);
|
||||
}
|
||||
}
|
||||
|
||||
private void attachChannels(List<Map<String, Object>> rows) {
|
||||
List<String> ids = rows.stream()
|
||||
.map(r -> {
|
||||
Object id = r.get("ID");
|
||||
if (id == null) id = r.get("id");
|
||||
return (String) id;
|
||||
})
|
||||
.filter(Objects::nonNull).toList();
|
||||
if (ids.isEmpty()) return;
|
||||
|
||||
var params = new MapSqlParameterSource();
|
||||
var placeholders = new ArrayList<String>();
|
||||
for (int i = 0; i < ids.size(); i++) {
|
||||
placeholders.add(":id" + i);
|
||||
params.addValue("id" + i, ids.get(i));
|
||||
}
|
||||
String sql = """
|
||||
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 (%s)
|
||||
""".formatted(String.join(", ", placeholders));
|
||||
|
||||
Map<String, List<String>> chMap = new HashMap<>();
|
||||
jdbc.query(sql, params, rs -> {
|
||||
chMap.computeIfAbsent(rs.getString("RESTAURANT_ID"), k -> new ArrayList<>())
|
||||
.add(rs.getString("CHANNEL_NAME"));
|
||||
});
|
||||
for (var r : rows) {
|
||||
String id = (String) r.get("ID");
|
||||
if (id == null) id = (String) r.get("id");
|
||||
r.put("channels", chMap.getOrDefault(id, List.of()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
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.repository.ChannelRepository;
|
||||
import com.tasteby.repository.VideoRepository;
|
||||
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 ChannelRepository channelRepo;
|
||||
private final VideoRepository videoRepo;
|
||||
private final String apiKey;
|
||||
|
||||
public YouTubeService(ObjectMapper mapper,
|
||||
ChannelRepository channelRepo,
|
||||
VideoRepository videoRepo,
|
||||
@Value("${app.google.youtube-api-key}") String apiKey) {
|
||||
this.webClient = WebClient.builder()
|
||||
.baseUrl("https://www.googleapis.com/youtube/v3")
|
||||
.build();
|
||||
this.mapper = mapper;
|
||||
this.channelRepo = channelRepo;
|
||||
this.videoRepo = videoRepo;
|
||||
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) {
|
||||
var ch = channelRepo.findByChannelId(channelId);
|
||||
if (ch == null) return null;
|
||||
|
||||
String dbId = (String) ch.get("ID");
|
||||
String titleFilter = (String) ch.get("TITLE_FILTER");
|
||||
String after = full ? null : videoRepo.getLatestVideoDate(dbId);
|
||||
Set<String> existing = videoRepo.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 = videoRepo.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() {
|
||||
var channels = channelRepo.findAllActive();
|
||||
int totalNew = 0;
|
||||
for (var ch : channels) {
|
||||
try {
|
||||
String chId = (String) ch.get("channel_id");
|
||||
var result = scanChannel(chId, false);
|
||||
if (result != null) {
|
||||
totalNew += ((Number) result.get("new_videos")).intValue();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to scan channel {}: {}", ch.get("channel_name"), 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);
|
||||
}
|
||||
}
|
||||
68
backend-java/src/main/java/com/tasteby/util/JsonUtil.java
Normal file
68
backend-java/src/main/java/com/tasteby/util/JsonUtil.java
Normal file
@@ -0,0 +1,68 @@
|
||||
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.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
63
backend-java/src/main/resources/application.yml
Normal file
63
backend-java/src/main/resources/application.yml
Normal file
@@ -0,0 +1,63 @@
|
||||
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
|
||||
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
|
||||
|
||||
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}
|
||||
|
||||
cache:
|
||||
ttl-seconds: 600
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.tasteby: DEBUG
|
||||
org.springframework.jdbc: DEBUG
|
||||
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
|
||||
Reference in New Issue
Block a user