From 6d05be23310a9fbf31965425b7ccc6f60044452a Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 9 Mar 2026 20:26:32 +0900 Subject: [PATCH] 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 --- .gitignore | 4 + backend-java/build.gradle | 67 +++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + backend-java/gradlew | 248 +++++++++ backend-java/gradlew.bat | 93 ++++ backend-java/settings.gradle | 1 + .../java/com/tasteby/TastebyApplication.java | 15 + .../com/tasteby/config/DataSourceConfig.java | 21 + .../config/GlobalExceptionHandler.java | 30 ++ .../java/com/tasteby/config/RedisConfig.java | 15 + .../com/tasteby/config/SecurityConfig.java | 46 ++ .../java/com/tasteby/config/WebConfig.java | 22 + .../controller/AdminUserController.java | 62 +++ .../tasteby/controller/AuthController.java | 30 ++ .../tasteby/controller/ChannelController.java | 69 +++ .../tasteby/controller/DaemonController.java | 82 +++ .../tasteby/controller/HealthController.java | 15 + .../controller/RestaurantController.java | 102 ++++ .../tasteby/controller/ReviewController.java | 96 ++++ .../tasteby/controller/SearchController.java | 27 + .../tasteby/controller/StatsController.java | 28 + .../tasteby/controller/VideoController.java | 77 +++ .../controller/VideoSseController.java | 402 ++++++++++++++ .../tasteby/repository/ChannelRepository.java | 74 +++ .../repository/RestaurantRepository.java | 340 ++++++++++++ .../tasteby/repository/ReviewRepository.java | 182 +++++++ .../tasteby/repository/StatsRepository.java | 43 ++ .../tasteby/repository/UserRepository.java | 101 ++++ .../tasteby/repository/VideoRepository.java | 220 ++++++++ .../java/com/tasteby/security/AuthUtil.java | 43 ++ .../security/JwtAuthenticationFilter.java | 48 ++ .../tasteby/security/JwtTokenProvider.java | 64 +++ .../java/com/tasteby/service/AuthService.java | 64 +++ .../com/tasteby/service/CacheService.java | 88 ++++ .../com/tasteby/service/DaemonScheduler.java | 115 ++++ .../com/tasteby/service/ExtractorService.java | 83 +++ .../com/tasteby/service/GeocodingService.java | 165 ++++++ .../com/tasteby/service/OciGenAiService.java | 177 +++++++ .../com/tasteby/service/PipelineService.java | 198 +++++++ .../com/tasteby/service/SearchService.java | 159 ++++++ .../com/tasteby/service/VectorService.java | 106 ++++ .../com/tasteby/service/YouTubeService.java | 492 ++++++++++++++++++ .../java/com/tasteby/util/CuisineTypes.java | 42 ++ .../main/java/com/tasteby/util/JsonUtil.java | 68 +++ .../java/com/tasteby/util/RegionParser.java | 94 ++++ .../src/main/resources/application.yml | 63 +++ backend-java/start.sh | 10 + ecosystem.config.js | 33 +- frontend/src/app/page.tsx | 36 +- 50 files changed, 4644 insertions(+), 23 deletions(-) create mode 100644 backend-java/build.gradle create mode 100644 backend-java/gradle/wrapper/gradle-wrapper.jar create mode 100644 backend-java/gradle/wrapper/gradle-wrapper.properties create mode 100755 backend-java/gradlew create mode 100644 backend-java/gradlew.bat create mode 100644 backend-java/settings.gradle create mode 100644 backend-java/src/main/java/com/tasteby/TastebyApplication.java create mode 100644 backend-java/src/main/java/com/tasteby/config/DataSourceConfig.java create mode 100644 backend-java/src/main/java/com/tasteby/config/GlobalExceptionHandler.java create mode 100644 backend-java/src/main/java/com/tasteby/config/RedisConfig.java create mode 100644 backend-java/src/main/java/com/tasteby/config/SecurityConfig.java create mode 100644 backend-java/src/main/java/com/tasteby/config/WebConfig.java create mode 100644 backend-java/src/main/java/com/tasteby/controller/AdminUserController.java create mode 100644 backend-java/src/main/java/com/tasteby/controller/AuthController.java create mode 100644 backend-java/src/main/java/com/tasteby/controller/ChannelController.java create mode 100644 backend-java/src/main/java/com/tasteby/controller/DaemonController.java create mode 100644 backend-java/src/main/java/com/tasteby/controller/HealthController.java create mode 100644 backend-java/src/main/java/com/tasteby/controller/RestaurantController.java create mode 100644 backend-java/src/main/java/com/tasteby/controller/ReviewController.java create mode 100644 backend-java/src/main/java/com/tasteby/controller/SearchController.java create mode 100644 backend-java/src/main/java/com/tasteby/controller/StatsController.java create mode 100644 backend-java/src/main/java/com/tasteby/controller/VideoController.java create mode 100644 backend-java/src/main/java/com/tasteby/controller/VideoSseController.java create mode 100644 backend-java/src/main/java/com/tasteby/repository/ChannelRepository.java create mode 100644 backend-java/src/main/java/com/tasteby/repository/RestaurantRepository.java create mode 100644 backend-java/src/main/java/com/tasteby/repository/ReviewRepository.java create mode 100644 backend-java/src/main/java/com/tasteby/repository/StatsRepository.java create mode 100644 backend-java/src/main/java/com/tasteby/repository/UserRepository.java create mode 100644 backend-java/src/main/java/com/tasteby/repository/VideoRepository.java create mode 100644 backend-java/src/main/java/com/tasteby/security/AuthUtil.java create mode 100644 backend-java/src/main/java/com/tasteby/security/JwtAuthenticationFilter.java create mode 100644 backend-java/src/main/java/com/tasteby/security/JwtTokenProvider.java create mode 100644 backend-java/src/main/java/com/tasteby/service/AuthService.java create mode 100644 backend-java/src/main/java/com/tasteby/service/CacheService.java create mode 100644 backend-java/src/main/java/com/tasteby/service/DaemonScheduler.java create mode 100644 backend-java/src/main/java/com/tasteby/service/ExtractorService.java create mode 100644 backend-java/src/main/java/com/tasteby/service/GeocodingService.java create mode 100644 backend-java/src/main/java/com/tasteby/service/OciGenAiService.java create mode 100644 backend-java/src/main/java/com/tasteby/service/PipelineService.java create mode 100644 backend-java/src/main/java/com/tasteby/service/SearchService.java create mode 100644 backend-java/src/main/java/com/tasteby/service/VectorService.java create mode 100644 backend-java/src/main/java/com/tasteby/service/YouTubeService.java create mode 100644 backend-java/src/main/java/com/tasteby/util/CuisineTypes.java create mode 100644 backend-java/src/main/java/com/tasteby/util/JsonUtil.java create mode 100644 backend-java/src/main/java/com/tasteby/util/RegionParser.java create mode 100644 backend-java/src/main/resources/application.yml create mode 100755 backend-java/start.sh diff --git a/.gitignore b/.gitignore index 5154d87..dba32ad 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ node_modules/ .next/ .env.local *.log + +# Java backend +backend-java/build/ +backend-java/.gradle/ diff --git a/backend-java/build.gradle b/backend-java/build.gradle new file mode 100644 index 0000000..4733de6 --- /dev/null +++ b/backend-java/build.gradle @@ -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() +} diff --git a/backend-java/gradle/wrapper/gradle-wrapper.jar b/backend-java/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d997cfc60f4cff0e7451d19d49a82fa986695d07 GIT binary patch literal 48966 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

`Lem|V$A>eR0uN8Y&sdjtu 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

1bvi{DS7E@q1{$_8->K{_OWv2 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 literal 0 HcmV?d00001 diff --git a/backend-java/gradle/wrapper/gradle-wrapper.properties b/backend-java/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df97d72 --- /dev/null +++ b/backend-java/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/backend-java/gradlew b/backend-java/gradlew new file mode 100755 index 0000000..0262dcb --- /dev/null +++ b/backend-java/gradlew @@ -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" "$@" diff --git a/backend-java/gradlew.bat b/backend-java/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/backend-java/gradlew.bat @@ -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 diff --git a/backend-java/settings.gradle b/backend-java/settings.gradle new file mode 100644 index 0000000..f6ad7b6 --- /dev/null +++ b/backend-java/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'tasteby-api' diff --git a/backend-java/src/main/java/com/tasteby/TastebyApplication.java b/backend-java/src/main/java/com/tasteby/TastebyApplication.java new file mode 100644 index 0000000..cc6b951 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/TastebyApplication.java @@ -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); + } +} diff --git a/backend-java/src/main/java/com/tasteby/config/DataSourceConfig.java b/backend-java/src/main/java/com/tasteby/config/DataSourceConfig.java new file mode 100644 index 0000000..f874e80 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/config/DataSourceConfig.java @@ -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); + } + } +} diff --git a/backend-java/src/main/java/com/tasteby/config/GlobalExceptionHandler.java b/backend-java/src/main/java/com/tasteby/config/GlobalExceptionHandler.java new file mode 100644 index 0000000..e13b0ca --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/config/GlobalExceptionHandler.java @@ -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> handleStatus(ResponseStatusException ex) { + return ResponseEntity.status(ex.getStatusCode()) + .body(Map.of("detail", ex.getReason() != null ? ex.getReason() : "Error")); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneral(Exception ex) { + log.error("Unhandled exception", ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("detail", "Internal server error")); + } +} diff --git a/backend-java/src/main/java/com/tasteby/config/RedisConfig.java b/backend-java/src/main/java/com/tasteby/config/RedisConfig.java new file mode 100644 index 0000000..c4afc08 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/config/RedisConfig.java @@ -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); + } +} diff --git a/backend-java/src/main/java/com/tasteby/config/SecurityConfig.java b/backend-java/src/main/java/com/tasteby/config/SecurityConfig.java new file mode 100644 index 0000000..168667f --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/config/SecurityConfig.java @@ -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(); + } +} diff --git a/backend-java/src/main/java/com/tasteby/config/WebConfig.java b/backend-java/src/main/java/com/tasteby/config/WebConfig.java new file mode 100644 index 0000000..cc0a923 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/config/WebConfig.java @@ -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); + } +} diff --git a/backend-java/src/main/java/com/tasteby/controller/AdminUserController.java b/backend-java/src/main/java/com/tasteby/controller/AdminUserController.java new file mode 100644 index 0000000..d25bc97 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/controller/AdminUserController.java @@ -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 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> 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> 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; + } +} diff --git a/backend-java/src/main/java/com/tasteby/controller/AuthController.java b/backend-java/src/main/java/com/tasteby/controller/AuthController.java new file mode 100644 index 0000000..947d1ef --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/controller/AuthController.java @@ -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 loginGoogle(@RequestBody Map body) { + String idToken = body.get("id_token"); + return authService.loginGoogle(idToken); + } + + @GetMapping("/me") + public Map me() { + String userId = AuthUtil.getUserId(); + return authService.getCurrentUser(userId); + } +} diff --git a/backend-java/src/main/java/com/tasteby/controller/ChannelController.java b/backend-java/src/main/java/com/tasteby/controller/ChannelController.java new file mode 100644 index 0000000..b1285eb --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/controller/ChannelController.java @@ -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> 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 create(@RequestBody Map 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 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); + } +} diff --git a/backend-java/src/main/java/com/tasteby/controller/DaemonController.java b/backend-java/src/main/java/com/tasteby/controller/DaemonController.java new file mode 100644 index 0000000..c39edc1 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/controller/DaemonController.java @@ -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 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(); + 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 updateConfig(@RequestBody Map body) { + AuthUtil.requireAdmin(); + var sets = new ArrayList(); + 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(); + } +} diff --git a/backend-java/src/main/java/com/tasteby/controller/HealthController.java b/backend-java/src/main/java/com/tasteby/controller/HealthController.java new file mode 100644 index 0000000..735c34e --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/controller/HealthController.java @@ -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 health() { + return Map.of("status", "ok"); + } +} diff --git a/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java b/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java new file mode 100644 index 0000000..df9b0ea --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java @@ -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> 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 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 update(@PathVariable String id, @RequestBody Map 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 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> 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; + } +} diff --git a/backend-java/src/main/java/com/tasteby/controller/ReviewController.java b/backend-java/src/main/java/com/tasteby/controller/ReviewController.java new file mode 100644 index 0000000..0abfaab --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/controller/ReviewController.java @@ -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 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 createReview( + @PathVariable String restaurantId, + @RequestBody Map 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 updateReview( + @PathVariable String reviewId, + @RequestBody Map 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> 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 favoriteStatus(@PathVariable String restaurantId) { + return Map.of("favorited", repo.isFavorited(AuthUtil.getUserId(), restaurantId)); + } + + @PostMapping("/restaurants/{restaurantId}/favorite") + public Map toggleFavorite(@PathVariable String restaurantId) { + boolean result = repo.toggleFavorite(AuthUtil.getUserId(), restaurantId); + return Map.of("favorited", result); + } + + @GetMapping("/users/me/favorites") + public List> myFavorites() { + return repo.getUserFavorites(AuthUtil.getUserId()); + } +} diff --git a/backend-java/src/main/java/com/tasteby/controller/SearchController.java b/backend-java/src/main/java/com/tasteby/controller/SearchController.java new file mode 100644 index 0000000..036b5d6 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/controller/SearchController.java @@ -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> 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); + } +} diff --git a/backend-java/src/main/java/com/tasteby/controller/StatsController.java b/backend-java/src/main/java/com/tasteby/controller/StatsController.java new file mode 100644 index 0000000..3e59292 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/controller/StatsController.java @@ -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 recordVisit() { + repo.recordVisit(); + return Map.of("ok", true); + } + + @GetMapping("/visits") + public Map getVisits() { + return repo.getVisits(); + } +} diff --git a/backend-java/src/main/java/com/tasteby/controller/VideoController.java b/backend-java/src/main/java/com/tasteby/controller/VideoController.java new file mode 100644 index 0000000..88ef587 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/controller/VideoController.java @@ -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> list(@RequestParam(required = false) String status) { + return repo.findAll(status); + } + + @GetMapping("/{id}") + public Map 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 updateTitle(@PathVariable String id, @RequestBody Map 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 skip(@PathVariable String id) { + AuthUtil.requireAdmin(); + repo.updateStatus(id, "skip"); + cache.flush(); + return Map.of("ok", true); + } + + @DeleteMapping("/{id}") + public Map delete(@PathVariable String id) { + AuthUtil.requireAdmin(); + repo.delete(id); + cache.flush(); + return Map.of("ok", true); + } + + @DeleteMapping("/{videoId}/restaurants/{restaurantId}") + public Map 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. +} diff --git a/backend-java/src/main/java/com/tasteby/controller/VideoSseController.java b/backend-java/src/main/java/com/tasteby/controller/VideoSseController.java new file mode 100644 index 0000000..2c564c5 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/controller/VideoSseController.java @@ -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 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 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>(); + + // 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 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>(); + + 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 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> missed) {} + + @SuppressWarnings("unchecked") + private BatchResult applyRemapBatch(List> 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> results = parsed instanceof List ? (List>) parsed : List.of(); + + Map 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>(); + 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> 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> results = parsed instanceof List ? (List>) parsed : List.of(); + + Map> 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>(); + for (var b : batch) { + String id = (String) b.get("id"); + List 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 data) { + try { + emitter.send(SseEmitter.event().data(mapper.writeValueAsString(data))); + } catch (Exception e) { + log.debug("SSE emit failed: {}", e.getMessage()); + } + } +} diff --git a/backend-java/src/main/java/com/tasteby/repository/ChannelRepository.java b/backend-java/src/main/java/com/tasteby/repository/ChannelRepository.java new file mode 100644 index 0000000..fb214d8 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/repository/ChannelRepository.java @@ -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> 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 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 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(); + } +} diff --git a/backend-java/src/main/java/com/tasteby/repository/RestaurantRepository.java b/backend-java/src/main/java/com/tasteby/repository/RestaurantRepository.java new file mode 100644 index 0000000..ccdeb32 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/repository/RestaurantRepository.java @@ -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> findAll(int limit, int offset, + String cuisine, String region, String channel) { + var conditions = new ArrayList(); + 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> rows = namedJdbc.queryForList(sql, params); + + if (!rows.isEmpty()) { + attachChannels(rows); + attachFoodsMentioned(rows); + } + return rows; + } + + public Map 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> rows = namedJdbc.queryForList(sql, params); + return rows.isEmpty() ? null : normalizeRow(rows.getFirst()); + } + + public List> 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 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 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 fields) { + var sets = new ArrayList(); + var params = new MapSqlParameterSource("rid", id); + + List 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 foods, String evaluation, List 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 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> rows) { + List 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(); + 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> 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> rows) { + List 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(); + 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> foodsMap = new HashMap<>(); + namedJdbc.query(sql, params, (rs) -> { + String rid = rs.getString("RESTAURANT_ID"); + List 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 all = foodsMap.getOrDefault(id, List.of()); + r.put("foods_mentioned", all.size() > 10 ? all.subList(0, 10) : all); + } + } + + private Map normalizeRow(Map row) { + // Oracle returns uppercase keys; normalize to lowercase + var result = new LinkedHashMap(); + for (var entry : row.entrySet()) { + result.put(entry.getKey().toLowerCase(), entry.getValue()); + } + return result; + } +} diff --git a/backend-java/src/main/java/com/tasteby/repository/ReviewRepository.java b/backend-java/src/main/java/com/tasteby/repository/ReviewRepository.java new file mode 100644 index 0000000..a11da3b --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/repository/ReviewRepository.java @@ -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 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 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 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> 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 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> 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> 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 mapReviewRow(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { + var m = new LinkedHashMap(); + 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; + } +} diff --git a/backend-java/src/main/java/com/tasteby/repository/StatsRepository.java b/backend-java/src/main/java/com/tasteby/repository/StatsRepository.java new file mode 100644 index 0000000..3d4ecb2 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/repository/StatsRepository.java @@ -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 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); + } +} diff --git a/backend-java/src/main/java/com/tasteby/repository/UserRepository.java b/backend-java/src/main/java/com/tasteby/repository/UserRepository.java new file mode 100644 index 0000000..9fe284d --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/repository/UserRepository.java @@ -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 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 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> 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; + } +} diff --git a/backend-java/src/main/java/com/tasteby/repository/VideoRepository.java b/backend-java/src/main/java/com/tasteby/repository/VideoRepository.java new file mode 100644 index 0000000..3d5999a --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/repository/VideoRepository.java @@ -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> 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 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 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 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 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> restaurants = jdbc.query(restSql, + new MapSqlParameterSource("vid", videoDbId), (rs, rowNum) -> { + Map 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> 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 getExistingVideoIds(String dbChannelId) { + String sql = "SELECT video_id FROM videos WHERE channel_id = :cid"; + List 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(); + } +} diff --git a/backend-java/src/main/java/com/tasteby/security/AuthUtil.java b/backend-java/src/main/java/com/tasteby/security/AuthUtil.java new file mode 100644 index 0000000..8d2411a --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/security/AuthUtil.java @@ -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(); + } +} diff --git a/backend-java/src/main/java/com/tasteby/security/JwtAuthenticationFilter.java b/backend-java/src/main/java/com/tasteby/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..1966161 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/security/JwtAuthenticationFilter.java @@ -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 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); + } +} diff --git a/backend-java/src/main/java/com/tasteby/security/JwtTokenProvider.java b/backend-java/src/main/java/com/tasteby/security/JwtTokenProvider.java new file mode 100644 index 0000000..1de23cc --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/security/JwtTokenProvider.java @@ -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 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; + } + } +} diff --git a/backend-java/src/main/java/com/tasteby/service/AuthService.java b/backend-java/src/main/java/com/tasteby/service/AuthService.java new file mode 100644 index 0000000..17a6344 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/AuthService.java @@ -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 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 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 getCurrentUser(String userId) { + Map user = userRepo.findById(userId); + if (user == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"); + } + return user; + } +} diff --git a/backend-java/src/main/java/com/tasteby/service/CacheService.java b/backend-java/src/main/java/com/tasteby/service/CacheService.java new file mode 100644 index 0000000..d92728e --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/CacheService.java @@ -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 get(String key, Class 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 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()); + } + } +} diff --git a/backend-java/src/main/java/com/tasteby/service/DaemonScheduler.java b/backend-java/src/main/java/com/tasteby/service/DaemonScheduler.java new file mode 100644 index 0000000..aa6e561 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/DaemonScheduler.java @@ -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(); + } +} diff --git a/backend-java/src/main/java/com/tasteby/service/ExtractorService.java b/backend-java/src/main/java/com/tasteby/service/ExtractorService.java new file mode 100644 index 0000000..fc3a877 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/ExtractorService.java @@ -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> 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>) list, raw); + } + if (result instanceof Map map) { + return new ExtractionResult(List.of((Map) map), raw); + } + return new ExtractionResult(Collections.emptyList(), raw); + } catch (Exception e) { + log.error("Restaurant extraction failed: {}", e.getMessage()); + return new ExtractionResult(Collections.emptyList(), ""); + } + } +} diff --git a/backend-java/src/main/java/com/tasteby/service/GeocodingService.java b/backend-java/src/main/java/com/tasteby/service/GeocodingService.java new file mode 100644 index 0000000..82044e5 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/GeocodingService.java @@ -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 geocodeRestaurant(String name, String address) { + String query = name; + if (address != null && !address.isBlank()) { + query += " " + address; + } + + // Try Places Text Search + Map result = placesTextSearch(query); + if (result != null) return result; + + // Fallback: Geocoding + return geocode(query); + } + + private Map 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(); + 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 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(); + 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 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(); + 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; + } + } +} diff --git a/backend-java/src/main/java/com/tasteby/service/OciGenAiService.java b/backend-java/src/main/java/com/tasteby/service/OciGenAiService.java new file mode 100644 index 0000000..38f6a42 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/OciGenAiService.java @@ -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> embedTexts(List texts) { + if (authProvider == null) throw new IllegalStateException("OCI GenAI not configured"); + + List> allEmbeddings = new ArrayList<>(); + for (int i = 0; i < texts.size(); i += EMBED_BATCH_SIZE) { + List batch = texts.subList(i, Math.min(i + EMBED_BATCH_SIZE, texts.size())); + allEmbeddings.addAll(embedBatch(batch)); + } + return allEmbeddings; + } + + private List> embedBatch(List 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 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()))); + } +} diff --git a/backend-java/src/main/java/com/tasteby/service/PipelineService.java b/backend-java/src/main/java/com/tasteby/service/PipelineService.java new file mode 100644 index 0000000..937eb0e --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/PipelineService.java @@ -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 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 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(); + 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) foods : null, + evaluation instanceof String ? (String) evaluation : null, + guests instanceof List ? (List) 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 normalizeKeys(Map row) { + var result = new HashMap(); + for (var entry : row.entrySet()) { + result.put(entry.getKey().toLowerCase(), entry.getValue()); + } + return result; + } +} diff --git a/backend-java/src/main/java/com/tasteby/service/SearchService.java b/backend-java/src/main/java/com/tasteby/service/SearchService.java new file mode 100644 index 0000000..5d0bce1 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/SearchService.java @@ -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> 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> result; + switch (mode) { + case "semantic" -> result = semanticSearch(q, limit); + case "hybrid" -> { + var kw = keywordSearch(q, limit); + var sem = semanticSearch(q, limit); + Set seen = new HashSet<>(); + var merged = new ArrayList>(); + 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> 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> rows = jdbc.queryForList(sql, params); + if (!rows.isEmpty()) { + attachChannels(rows); + } + return rows; + } + + private List> 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 seen = new LinkedHashSet<>(); + for (var s : similar) { + seen.add((String) s.get("restaurant_id")); + } + + List> 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> rows) { + List 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(); + 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> 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())); + } + } +} diff --git a/backend-java/src/main/java/com/tasteby/service/VectorService.java b/backend-java/src/main/java/com/tasteby/service/VectorService.java new file mode 100644 index 0000000..e738a2a --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/VectorService.java @@ -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> searchSimilar(String query, int topK, double maxDistance) { + List> 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 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 chunks) { + if (chunks.isEmpty()) return; + + List> 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 buildChunks(String name, Map data, String videoTitle) { + var parts = new ArrayList(); + 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)); + } +} diff --git a/backend-java/src/main/java/com/tasteby/service/YouTubeService.java b/backend-java/src/main/java/com/tasteby/service/YouTubeService.java new file mode 100644 index 0000000..49bb450 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/YouTubeService.java @@ -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> fetchChannelVideos(String channelId, String publishedAfter, boolean excludeShorts) { + List> 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> 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> filterShorts(List> 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 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 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 existing = videoRepo.getExistingVideoIds(dbId); + + List> allFetched = fetchChannelVideos(channelId, after, true); + int totalFetched = allFetched.size(); + + List> 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 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 lines = java.nio.file.Files.readAllLines(cookieFile); + List 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()); + } + } +} diff --git a/backend-java/src/main/java/com/tasteby/util/CuisineTypes.java b/backend-java/src/main/java/com/tasteby/util/CuisineTypes.java new file mode 100644 index 0000000..c5ad168 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/util/CuisineTypes.java @@ -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 ALL = List.of( + "한식|백반/한정식", "한식|국밥/해장국", "한식|찌개/전골/탕", "한식|삼겹살/돼지구이", + "한식|소고기/한우구이", "한식|곱창/막창", "한식|닭/오리구이", "한식|족발/보쌈", + "한식|회/횟집", "한식|해산물", "한식|분식", "한식|면", "한식|죽/죽집", + "한식|순대/순대국", "한식|장어/민물", "한식|주점/포차", + "일식|스시/오마카세", "일식|라멘", "일식|돈카츠", "일식|텐동/튀김", + "일식|이자카야", "일식|야키니쿠", "일식|카레", "일식|소바/우동", + "중식|중화요리", "중식|마라/훠궈", "중식|딤섬/만두", "중식|양꼬치", + "양식|파스타/이탈리안", "양식|스테이크", "양식|햄버거", "양식|피자", + "양식|프렌치", "양식|바베큐", "양식|브런치", "양식|비건/샐러드", + "아시아|베트남", "아시아|태국", "아시아|인도/중동", "아시아|동남아기타", + "기타|치킨", "기타|카페/디저트", "기타|베이커리", "기타|뷔페", "기타|퓨전" + ); + + public static final Set VALID_SET = Set.copyOf(ALL); + + public static final List 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); + } +} diff --git a/backend-java/src/main/java/com/tasteby/util/JsonUtil.java b/backend-java/src/main/java/com/tasteby/util/JsonUtil.java new file mode 100644 index 0000000..1d0b996 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/util/JsonUtil.java @@ -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 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 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 "{}"; + } + } +} diff --git a/backend-java/src/main/java/com/tasteby/util/RegionParser.java b/backend-java/src/main/java/com/tasteby/util/RegionParser.java new file mode 100644 index 0000000..defd2d2 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/util/RegionParser.java @@ -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 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; + } +} diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml new file mode 100644 index 0000000..23f083d --- /dev/null +++ b/backend-java/src/main/resources/application.yml @@ -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 diff --git a/backend-java/start.sh b/backend-java/start.sh new file mode 100755 index 0000000..5345b84 --- /dev/null +++ b/backend-java/start.sh @@ -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 diff --git a/ecosystem.config.js b/ecosystem.config.js index 4741516..bce7433 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -2,22 +2,25 @@ module.exports = { apps: [ { name: "tasteby-api", - cwd: "/Users/joungmin/workspaces/tasteby/backend", - script: "run_api.py", - interpreter: "/Users/joungmin/workspaces/tasteby/backend/.venv/bin/python", - env: { - PYTHONPATH: ".", - }, - }, - { - name: "tasteby-daemon", - cwd: "/Users/joungmin/workspaces/tasteby/backend", - script: "run_daemon.py", - interpreter: "/Users/joungmin/workspaces/tasteby/backend/.venv/bin/python", - env: { - PYTHONPATH: ".", - }, + cwd: "/Users/joungmin/workspaces/tasteby/backend-java", + script: "./start.sh", + interpreter: "/bin/bash", }, + // Python backend (disabled - kept for rollback) + // { + // name: "tasteby-api-python", + // cwd: "/Users/joungmin/workspaces/tasteby/backend", + // script: "run_api.py", + // interpreter: "/Users/joungmin/workspaces/tasteby/backend/.venv/bin/python", + // env: { PYTHONPATH: "." }, + // }, + // { + // name: "tasteby-daemon-python", + // cwd: "/Users/joungmin/workspaces/tasteby/backend", + // script: "run_daemon.py", + // interpreter: "/Users/joungmin/workspaces/tasteby/backend/.venv/bin/python", + // env: { PYTHONPATH: "." }, + // }, { name: "tasteby-web", cwd: "/Users/joungmin/workspaces/tasteby/frontend", diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index f3a0eb3..81f6115 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -11,6 +11,7 @@ import RestaurantList from "@/components/RestaurantList"; import RestaurantDetail from "@/components/RestaurantDetail"; import MyReviewsList from "@/components/MyReviewsList"; import BottomSheet from "@/components/BottomSheet"; +import { getCuisineIcon } from "@/lib/cuisine-icons"; const CUISINE_GROUPS: { label: string; prefix: string }[] = [ { label: "한식", prefix: "한식" }, @@ -819,15 +820,34 @@ export default function Home() { ) : ( <> -
- {mobileListContent} - {/* Scroll-down hint to reveal map */} -
- - 아래로 스크롤하면 지도 + {/* List area — if selected, show single row; otherwise full list */} + {selected ? ( +
{ setSelected(null); setShowDetail(false); }} + > + {getCuisineIcon(selected.cuisine_type)} + {selected.name} + {selected.rating && ( + {selected.rating} + )} + {selected.cuisine_type && ( + {selected.cuisine_type} + )} +
-
-
+ ) : ( +
+ {mobileListContent} +
+ )} + {/* Map fills remaining space below the list */} +