Compare commits
29 Commits
f54da90b5f
...
v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f985d52a9 | ||
|
|
cdee37e341 | ||
|
|
58c0f972e2 | ||
|
|
0ad09e5b67 | ||
|
|
7a896c8c56 | ||
|
|
745913ca5b | ||
|
|
293c59060c | ||
|
|
ff4e8d742d | ||
|
|
69e1882c2b | ||
|
|
16bd83c570 | ||
|
|
c16add08c3 | ||
|
|
91d0ad4598 | ||
|
|
a844fd44cc | ||
|
|
6d05be2331 | ||
|
|
161b1383be | ||
|
|
d39b3b8fea | ||
|
|
b3923dcc72 | ||
|
|
6223691b33 | ||
|
|
99660bf07b | ||
|
|
d1ef156f44 | ||
|
|
4a1c8cf1cd | ||
|
|
2cd72d660a | ||
|
|
17489ad9b0 | ||
|
|
08ea282baf | ||
|
|
237c982e6c | ||
|
|
758d87842b | ||
|
|
d4d516a375 | ||
|
|
54d21afd52 | ||
|
|
a5b3598f8a |
11
.gitignore
vendored
11
.gitignore
vendored
@@ -6,3 +6,14 @@ node_modules/
|
||||
.next/
|
||||
.env.local
|
||||
*.log
|
||||
|
||||
# Java backend
|
||||
backend-java/build/
|
||||
backend-java/.gradle/
|
||||
|
||||
# K8s secrets (never commit)
|
||||
k8s/secrets.yaml
|
||||
|
||||
# OS / misc
|
||||
.DS_Store
|
||||
backend/cookies.txt
|
||||
|
||||
4
backend-java/.dockerignore
Normal file
4
backend-java/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
build/
|
||||
.gradle/
|
||||
.idea/
|
||||
*.iml
|
||||
16
backend-java/Dockerfile
Normal file
16
backend-java/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
# ── Build stage ──
|
||||
FROM eclipse-temurin:21-jdk AS build
|
||||
WORKDIR /app
|
||||
COPY gradlew settings.gradle build.gradle ./
|
||||
COPY gradle/ gradle/
|
||||
RUN chmod +x gradlew && ./gradlew dependencies --no-daemon || true
|
||||
COPY src/ src/
|
||||
RUN ./gradlew bootJar -x test --no-daemon
|
||||
|
||||
# ── Runtime stage ──
|
||||
FROM eclipse-temurin:21-jre
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/build/libs/*.jar app.jar
|
||||
EXPOSE 8000
|
||||
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"
|
||||
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
|
||||
71
backend-java/build.gradle
Normal file
71
backend-java/build.gradle
Normal file
@@ -0,0 +1,71 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'org.springframework.boot' version '3.3.5'
|
||||
id 'io.spring.dependency-management' version '1.1.6'
|
||||
}
|
||||
|
||||
group = 'com.tasteby'
|
||||
version = '0.1.0'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Spring Boot
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
|
||||
// MyBatis
|
||||
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.4'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
|
||||
// Oracle JDBC + Security (Wallet support for Oracle ADB)
|
||||
implementation 'com.oracle.database.jdbc:ojdbc11:23.7.0.25.01'
|
||||
implementation 'com.oracle.database.security:oraclepki:23.7.0.25.01'
|
||||
|
||||
// JWT
|
||||
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
|
||||
|
||||
// Google OAuth2 token verification
|
||||
implementation 'com.google.api-client:google-api-client:2.7.0'
|
||||
|
||||
// OCI SDK (GenAI for LLM + Embeddings)
|
||||
implementation 'com.oracle.oci.sdk:oci-java-sdk-generativeaiinference:3.49.0'
|
||||
implementation 'com.oracle.oci.sdk:oci-java-sdk-common:3.49.0'
|
||||
implementation 'com.oracle.oci.sdk:oci-java-sdk-common-httpclient-jersey3:3.49.0'
|
||||
|
||||
// Jackson for JSON
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind'
|
||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
|
||||
|
||||
// YouTube Transcript API (Java port of youtube-transcript-api)
|
||||
implementation 'io.github.thoroldvix:youtube-transcript-api:0.4.0'
|
||||
|
||||
// Playwright (browser-based transcript fallback)
|
||||
implementation 'com.microsoft.playwright:playwright:1.49.0'
|
||||
|
||||
// HTTP client for YouTube/Google APIs
|
||||
implementation 'org.springframework.boot:spring-boot-starter-webflux'
|
||||
|
||||
// Lombok
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
annotationProcessor 'org.projectlombok:lombok'
|
||||
|
||||
// Test
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
BIN
backend-java/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
backend-java/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
backend-java/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
backend-java/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
248
backend-java/gradlew
vendored
Executable file
248
backend-java/gradlew
vendored
Executable file
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
93
backend-java/gradlew.bat
vendored
Normal file
93
backend-java/gradlew.bat
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
1
backend-java/settings.gradle
Normal file
1
backend-java/settings.gradle
Normal file
@@ -0,0 +1 @@
|
||||
rootProject.name = 'tasteby-api'
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.tasteby;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
public class TastebyApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(TastebyApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.tasteby.config;
|
||||
|
||||
import org.apache.ibatis.type.BaseTypeHandler;
|
||||
import org.apache.ibatis.type.JdbcType;
|
||||
import org.apache.ibatis.type.MappedJdbcTypes;
|
||||
import org.apache.ibatis.type.MappedTypes;
|
||||
|
||||
import java.io.Reader;
|
||||
import java.sql.*;
|
||||
|
||||
@MappedTypes(String.class)
|
||||
@MappedJdbcTypes(JdbcType.CLOB)
|
||||
public class ClobTypeHandler extends BaseTypeHandler<String> {
|
||||
|
||||
@Override
|
||||
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType)
|
||||
throws SQLException {
|
||||
ps.setString(i, parameter);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
|
||||
return clobToString(rs.getClob(columnName));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
|
||||
return clobToString(rs.getClob(columnIndex));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
|
||||
return clobToString(cs.getClob(columnIndex));
|
||||
}
|
||||
|
||||
private String clobToString(Clob clob) {
|
||||
if (clob == null) return null;
|
||||
try (Reader reader = clob.getCharacterStream()) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
char[] buf = new char[4096];
|
||||
int len;
|
||||
while ((len = reader.read(buf)) != -1) {
|
||||
sb.append(buf, 0, len);
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.tasteby.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.event.EventListener;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
@Configuration
|
||||
public class DataSourceConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DataSourceConfig.class);
|
||||
|
||||
@Value("${app.oracle.wallet-path:}")
|
||||
private String walletPath;
|
||||
|
||||
private final DataSource dataSource;
|
||||
|
||||
public DataSourceConfig(DataSource dataSource) {
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void configureWallet() {
|
||||
if (walletPath != null && !walletPath.isBlank()) {
|
||||
System.setProperty("oracle.net.tns_admin", walletPath);
|
||||
System.setProperty("oracle.net.wallet_location", walletPath);
|
||||
}
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void runMigrations() {
|
||||
migrate("ALTER TABLE restaurants ADD (tabling_url VARCHAR2(500))");
|
||||
migrate("ALTER TABLE restaurants ADD (catchtable_url VARCHAR2(500))");
|
||||
}
|
||||
|
||||
private void migrate(String sql) {
|
||||
try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
|
||||
stmt.execute(sql);
|
||||
log.info("[MIGRATE] {}", sql);
|
||||
} catch (Exception e) {
|
||||
if (e.getMessage() != null && e.getMessage().contains("ORA-01430")) {
|
||||
log.debug("[MIGRATE] already done: {}", sql);
|
||||
} else {
|
||||
log.warn("[MIGRATE] failed: {} - {}", sql, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.tasteby.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleStatus(ResponseStatusException ex) {
|
||||
return ResponseEntity.status(ex.getStatusCode())
|
||||
.body(Map.of("detail", ex.getReason() != null ? ex.getReason() : "Error"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Map<String, Object>> handleGeneral(Exception ex) {
|
||||
log.error("Unhandled exception", ex);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("detail", "Internal server error"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.tasteby.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
@Configuration
|
||||
public class RedisConfig {
|
||||
|
||||
@Bean
|
||||
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
|
||||
return new StringRedisTemplate(connectionFactory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.tasteby.config;
|
||||
|
||||
import com.tasteby.security.JwtAuthenticationFilter;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtFilter;
|
||||
|
||||
public SecurityConfig(JwtAuthenticationFilter jwtFilter) {
|
||||
this.jwtFilter = jwtFilter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.cors(cors -> {})
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// Public endpoints
|
||||
.requestMatchers("/api/health").permitAll()
|
||||
.requestMatchers("/api/auth/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/restaurants/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/channels").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/search").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/restaurants/*/reviews").permitAll()
|
||||
.requestMatchers("/api/stats/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/daemon/config").permitAll()
|
||||
// Everything else requires authentication (controller-level admin checks)
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
32
backend-java/src/main/java/com/tasteby/config/WebConfig.java
Normal file
32
backend-java/src/main/java/com/tasteby/config/WebConfig.java
Normal file
@@ -0,0 +1,32 @@
|
||||
package com.tasteby.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Value("${app.cors.allowed-origins}")
|
||||
private String allowedOrigins;
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", config);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.domain.Review;
|
||||
import com.tasteby.service.ReviewService;
|
||||
import com.tasteby.service.UserService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/users")
|
||||
public class AdminUserController {
|
||||
|
||||
private final UserService userService;
|
||||
private final ReviewService reviewService;
|
||||
|
||||
public AdminUserController(UserService userService, ReviewService reviewService) {
|
||||
this.userService = userService;
|
||||
this.reviewService = reviewService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public Map<String, Object> listUsers(
|
||||
@RequestParam(defaultValue = "50") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset) {
|
||||
var users = userService.findAllWithCounts(limit, offset);
|
||||
int total = userService.countAll();
|
||||
return Map.of("users", users, "total", total);
|
||||
}
|
||||
|
||||
@GetMapping("/{userId}/favorites")
|
||||
public List<Restaurant> userFavorites(@PathVariable String userId) {
|
||||
return reviewService.getUserFavorites(userId);
|
||||
}
|
||||
|
||||
@GetMapping("/{userId}/reviews")
|
||||
public List<Review> userReviews(@PathVariable String userId) {
|
||||
return reviewService.findByUser(userId, 100, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.domain.UserInfo;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.AuthService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
public AuthController(AuthService authService) {
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
@PostMapping("/google")
|
||||
public Map<String, Object> loginGoogle(@RequestBody Map<String, String> body) {
|
||||
String idToken = body.get("id_token");
|
||||
return authService.loginGoogle(idToken);
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public UserInfo me() {
|
||||
String userId = AuthUtil.getUserId();
|
||||
return authService.getCurrentUser(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.tasteby.domain.Channel;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.CacheService;
|
||||
import com.tasteby.service.ChannelService;
|
||||
import com.tasteby.service.YouTubeService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/channels")
|
||||
public class ChannelController {
|
||||
|
||||
private final ChannelService channelService;
|
||||
private final YouTubeService youtubeService;
|
||||
private final CacheService cache;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ChannelController(ChannelService channelService, YouTubeService youtubeService,
|
||||
CacheService cache, ObjectMapper objectMapper) {
|
||||
this.channelService = channelService;
|
||||
this.youtubeService = youtubeService;
|
||||
this.cache = cache;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Channel> list() {
|
||||
String key = cache.makeKey("channels");
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
return objectMapper.readValue(cached, new TypeReference<List<Channel>>() {});
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
var result = channelService.findAllActive();
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public Map<String, Object> create(@RequestBody Map<String, String> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
String channelId = body.get("channel_id");
|
||||
String channelName = body.get("channel_name");
|
||||
String titleFilter = body.get("title_filter");
|
||||
try {
|
||||
String id = channelService.create(channelId, channelName, titleFilter);
|
||||
cache.flush();
|
||||
return Map.of("id", id, "channel_id", channelId);
|
||||
} catch (Exception e) {
|
||||
if (e.getMessage() != null && e.getMessage().toUpperCase().contains("UQ_CHANNELS_CID")) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Channel already exists");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/{channelId}/scan")
|
||||
public Map<String, Object> scan(@PathVariable String channelId,
|
||||
@RequestParam(defaultValue = "false") boolean full) {
|
||||
AuthUtil.requireAdmin();
|
||||
var result = youtubeService.scanChannel(channelId, full);
|
||||
if (result == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Channel not found");
|
||||
}
|
||||
cache.flush();
|
||||
return result;
|
||||
}
|
||||
|
||||
@DeleteMapping("/{channelId}")
|
||||
public Map<String, Object> delete(@PathVariable String channelId) {
|
||||
AuthUtil.requireAdmin();
|
||||
if (!channelService.deactivate(channelId)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Channel not found");
|
||||
}
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.domain.DaemonConfig;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.DaemonConfigService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/daemon")
|
||||
public class DaemonController {
|
||||
|
||||
private final DaemonConfigService daemonConfigService;
|
||||
|
||||
public DaemonController(DaemonConfigService daemonConfigService) {
|
||||
this.daemonConfigService = daemonConfigService;
|
||||
}
|
||||
|
||||
@GetMapping("/config")
|
||||
public DaemonConfig getConfig() {
|
||||
DaemonConfig config = daemonConfigService.getConfig();
|
||||
return config != null ? config : DaemonConfig.builder().build();
|
||||
}
|
||||
|
||||
@PutMapping("/config")
|
||||
public Map<String, Object> updateConfig(@RequestBody Map<String, Object> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
daemonConfigService.updateConfig(body);
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
public class HealthController {
|
||||
|
||||
@GetMapping("/api/health")
|
||||
public Map<String, String> health() {
|
||||
return Map.of("status", "ok");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.microsoft.playwright.*;
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.CacheService;
|
||||
import com.tasteby.service.GeocodingService;
|
||||
import com.tasteby.service.RestaurantService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/restaurants")
|
||||
public class RestaurantController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RestaurantController.class);
|
||||
|
||||
private final RestaurantService restaurantService;
|
||||
private final GeocodingService geocodingService;
|
||||
private final CacheService cache;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
public RestaurantController(RestaurantService restaurantService, GeocodingService geocodingService, CacheService cache, ObjectMapper objectMapper) {
|
||||
this.restaurantService = restaurantService;
|
||||
this.geocodingService = geocodingService;
|
||||
this.cache = cache;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Restaurant> list(
|
||||
@RequestParam(defaultValue = "100") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset,
|
||||
@RequestParam(required = false) String cuisine,
|
||||
@RequestParam(required = false) String region,
|
||||
@RequestParam(required = false) String channel) {
|
||||
if (limit > 500) limit = 500;
|
||||
String key = cache.makeKey("restaurants", "l=" + limit, "o=" + offset,
|
||||
"c=" + cuisine, "r=" + region, "ch=" + channel);
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
var result = restaurantService.findAll(limit, offset, cuisine, region, channel);
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public Restaurant get(@PathVariable String id) {
|
||||
String key = cache.makeKey("restaurant", id);
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
return objectMapper.readValue(cached, Restaurant.class);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
cache.set(key, r);
|
||||
return r;
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
|
||||
// Re-geocode if name or address changed
|
||||
String newName = (String) body.get("name");
|
||||
String newAddress = (String) body.get("address");
|
||||
boolean nameChanged = newName != null && !newName.equals(r.getName());
|
||||
boolean addressChanged = newAddress != null && !newAddress.equals(r.getAddress());
|
||||
if (nameChanged || addressChanged) {
|
||||
String geoName = newName != null ? newName : r.getName();
|
||||
String geoAddr = newAddress != null ? newAddress : r.getAddress();
|
||||
var geo = geocodingService.geocodeRestaurant(geoName, geoAddr);
|
||||
if (geo != null) {
|
||||
body.put("latitude", geo.get("latitude"));
|
||||
body.put("longitude", geo.get("longitude"));
|
||||
body.put("google_place_id", geo.get("google_place_id"));
|
||||
if (geo.containsKey("formatted_address")) {
|
||||
body.put("address", geo.get("formatted_address"));
|
||||
}
|
||||
if (geo.containsKey("rating")) body.put("rating", geo.get("rating"));
|
||||
if (geo.containsKey("rating_count")) body.put("rating_count", geo.get("rating_count"));
|
||||
if (geo.containsKey("phone")) body.put("phone", geo.get("phone"));
|
||||
if (geo.containsKey("business_status")) body.put("business_status", geo.get("business_status"));
|
||||
|
||||
// formatted_address에서 region 파싱 (예: "대한민국 서울특별시 강남구 ..." → "한국|서울|강남구")
|
||||
String addr = (String) geo.get("formatted_address");
|
||||
if (addr != null) {
|
||||
body.put("region", GeocodingService.parseRegionFromAddress(addr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
restaurantService.update(id, body);
|
||||
cache.flush();
|
||||
var updated = restaurantService.findById(id);
|
||||
return Map.of("ok", true, "restaurant", updated);
|
||||
}
|
||||
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public Map<String, Object> delete(@PathVariable String id) {
|
||||
AuthUtil.requireAdmin();
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
restaurantService.delete(id);
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
/** 단건 테이블링 URL 검색 */
|
||||
@GetMapping("/{id}/tabling-search")
|
||||
public List<Map<String, Object>> tablingSearch(@PathVariable String id) {
|
||||
AuthUtil.requireAdmin();
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
|
||||
try (Playwright pw = Playwright.create()) {
|
||||
try (Browser browser = launchBrowser(pw)) {
|
||||
BrowserContext ctx = newContext(browser);
|
||||
Page page = newPage(ctx);
|
||||
return searchTabling(page, r.getName());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[TABLING] Search failed for '{}': {}", r.getName(), e.getMessage());
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 테이블링 미연결 식당 목록 */
|
||||
@GetMapping("/tabling-pending")
|
||||
public Map<String, Object> tablingPending() {
|
||||
AuthUtil.requireAdmin();
|
||||
var list = restaurantService.findWithoutTabling();
|
||||
var summary = list.stream()
|
||||
.map(r -> Map.of("id", (Object) r.getId(), "name", (Object) r.getName()))
|
||||
.toList();
|
||||
return Map.of("count", list.size(), "restaurants", summary);
|
||||
}
|
||||
|
||||
/** 벌크 테이블링 검색 (SSE) */
|
||||
@PostMapping("/bulk-tabling")
|
||||
public SseEmitter bulkTabling() {
|
||||
AuthUtil.requireAdmin();
|
||||
SseEmitter emitter = new SseEmitter(600_000L);
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
var restaurants = restaurantService.findWithoutTabling();
|
||||
int total = restaurants.size();
|
||||
emit(emitter, Map.of("type", "start", "total", total));
|
||||
|
||||
if (total == 0) {
|
||||
emit(emitter, Map.of("type", "complete", "total", 0, "linked", 0, "notFound", 0));
|
||||
emitter.complete();
|
||||
return;
|
||||
}
|
||||
|
||||
int linked = 0;
|
||||
int notFound = 0;
|
||||
|
||||
try (Playwright pw = Playwright.create()) {
|
||||
try (Browser browser = launchBrowser(pw)) {
|
||||
BrowserContext ctx = newContext(browser);
|
||||
Page page = newPage(ctx);
|
||||
|
||||
for (int i = 0; i < total; i++) {
|
||||
var r = restaurants.get(i);
|
||||
emit(emitter, Map.of("type", "processing", "current", i + 1,
|
||||
"total", total, "name", r.getName()));
|
||||
|
||||
try {
|
||||
var results = searchTabling(page, r.getName());
|
||||
if (!results.isEmpty()) {
|
||||
String url = String.valueOf(results.get(0).get("url"));
|
||||
String title = String.valueOf(results.get(0).get("title"));
|
||||
restaurantService.update(r.getId(), Map.of("tabling_url", url));
|
||||
linked++;
|
||||
emit(emitter, Map.of("type", "done", "current", i + 1,
|
||||
"name", r.getName(), "url", url, "title", title));
|
||||
} else {
|
||||
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
|
||||
notFound++;
|
||||
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
||||
"name", r.getName()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
notFound++;
|
||||
emit(emitter, Map.of("type", "error", "current", i + 1,
|
||||
"name", r.getName(), "message", e.getMessage()));
|
||||
}
|
||||
|
||||
// Google 봇 판정 방지 랜덤 딜레이 (5~15초)
|
||||
int delay = ThreadLocalRandom.current().nextInt(5000, 15001);
|
||||
log.info("[TABLING] Waiting {}ms before next search...", delay);
|
||||
page.waitForTimeout(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cache.flush();
|
||||
emit(emitter, Map.of("type", "complete", "total", total, "linked", linked, "notFound", notFound));
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
log.error("[TABLING] Bulk search error", e);
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/** 테이블링 URL 저장 */
|
||||
@PutMapping("/{id}/tabling-url")
|
||||
public Map<String, Object> setTablingUrl(@PathVariable String id, @RequestBody Map<String, String> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
String url = body.get("tabling_url");
|
||||
restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
/** 단건 캐치테이블 URL 검색 */
|
||||
@GetMapping("/{id}/catchtable-search")
|
||||
public List<Map<String, Object>> catchtableSearch(@PathVariable String id) {
|
||||
AuthUtil.requireAdmin();
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
try (Playwright pw = Playwright.create()) {
|
||||
try (Browser browser = launchBrowser(pw)) {
|
||||
BrowserContext ctx = newContext(browser);
|
||||
Page page = newPage(ctx);
|
||||
return searchCatchtable(page, r.getName());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[CATCHTABLE] Search failed for '{}': {}", r.getName(), e.getMessage());
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 캐치테이블 미연결 식당 목록 */
|
||||
@GetMapping("/catchtable-pending")
|
||||
public Map<String, Object> catchtablePending() {
|
||||
AuthUtil.requireAdmin();
|
||||
var list = restaurantService.findWithoutCatchtable();
|
||||
var summary = list.stream()
|
||||
.map(r -> Map.of("id", (Object) r.getId(), "name", (Object) r.getName()))
|
||||
.toList();
|
||||
return Map.of("count", list.size(), "restaurants", summary);
|
||||
}
|
||||
|
||||
/** 벌크 캐치테이블 검색 (SSE) */
|
||||
@PostMapping("/bulk-catchtable")
|
||||
public SseEmitter bulkCatchtable() {
|
||||
AuthUtil.requireAdmin();
|
||||
SseEmitter emitter = new SseEmitter(600_000L);
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
var restaurants = restaurantService.findWithoutCatchtable();
|
||||
int total = restaurants.size();
|
||||
emit(emitter, Map.of("type", "start", "total", total));
|
||||
|
||||
if (total == 0) {
|
||||
emit(emitter, Map.of("type", "complete", "total", 0, "linked", 0, "notFound", 0));
|
||||
emitter.complete();
|
||||
return;
|
||||
}
|
||||
|
||||
int linked = 0;
|
||||
int notFound = 0;
|
||||
|
||||
try (Playwright pw = Playwright.create()) {
|
||||
try (Browser browser = launchBrowser(pw)) {
|
||||
BrowserContext ctx = newContext(browser);
|
||||
Page page = newPage(ctx);
|
||||
|
||||
for (int i = 0; i < total; i++) {
|
||||
var r = restaurants.get(i);
|
||||
emit(emitter, Map.of("type", "processing", "current", i + 1,
|
||||
"total", total, "name", r.getName()));
|
||||
|
||||
try {
|
||||
var results = searchCatchtable(page, r.getName());
|
||||
if (!results.isEmpty()) {
|
||||
String url = String.valueOf(results.get(0).get("url"));
|
||||
String title = String.valueOf(results.get(0).get("title"));
|
||||
restaurantService.update(r.getId(), Map.of("catchtable_url", url));
|
||||
linked++;
|
||||
emit(emitter, Map.of("type", "done", "current", i + 1,
|
||||
"name", r.getName(), "url", url, "title", title));
|
||||
} else {
|
||||
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
|
||||
notFound++;
|
||||
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
||||
"name", r.getName()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
notFound++;
|
||||
emit(emitter, Map.of("type", "error", "current", i + 1,
|
||||
"name", r.getName(), "message", e.getMessage()));
|
||||
}
|
||||
|
||||
int delay = ThreadLocalRandom.current().nextInt(5000, 15001);
|
||||
log.info("[CATCHTABLE] Waiting {}ms before next search...", delay);
|
||||
page.waitForTimeout(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cache.flush();
|
||||
emit(emitter, Map.of("type", "complete", "total", total, "linked", linked, "notFound", notFound));
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
log.error("[CATCHTABLE] Bulk search error", e);
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/** 캐치테이블 URL 저장 */
|
||||
@PutMapping("/{id}/catchtable-url")
|
||||
public Map<String, Object> setCatchtableUrl(@PathVariable String id, @RequestBody Map<String, String> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
String url = body.get("catchtable_url");
|
||||
restaurantService.update(id, Map.of("catchtable_url", url != null ? url : ""));
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/videos")
|
||||
public List<Map<String, Object>> videos(@PathVariable String id) {
|
||||
String key = cache.makeKey("restaurant_videos", id);
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {});
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
var result = restaurantService.findVideoLinks(id);
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Playwright helpers ──────────────────────────────────────────────
|
||||
|
||||
private Browser launchBrowser(Playwright pw) {
|
||||
return pw.chromium().launch(new BrowserType.LaunchOptions()
|
||||
.setHeadless(false)
|
||||
.setArgs(List.of("--disable-blink-features=AutomationControlled")));
|
||||
}
|
||||
|
||||
private BrowserContext newContext(Browser browser) {
|
||||
return browser.newContext(new Browser.NewContextOptions()
|
||||
.setLocale("ko-KR").setViewportSize(1280, 900));
|
||||
}
|
||||
|
||||
private Page newPage(BrowserContext ctx) {
|
||||
Page page = ctx.newPage();
|
||||
page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})");
|
||||
return page;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Map<String, Object>> searchTabling(Page page, String restaurantName) {
|
||||
String query = "site:tabling.co.kr " + restaurantName;
|
||||
log.info("[TABLING] Searching: {}", query);
|
||||
|
||||
String searchUrl = "https://www.google.com/search?q=" +
|
||||
URLEncoder.encode(query, StandardCharsets.UTF_8);
|
||||
page.navigate(searchUrl);
|
||||
page.waitForTimeout(3000);
|
||||
|
||||
Object linksObj = page.evaluate("""
|
||||
() => {
|
||||
const results = [];
|
||||
const links = document.querySelectorAll('a[href]');
|
||||
for (const a of links) {
|
||||
const href = a.href;
|
||||
if (href.includes('tabling.co.kr/restaurant/') || href.includes('tabling.co.kr/place/')) {
|
||||
const title = a.closest('[data-header-feature]')?.querySelector('h3')?.textContent
|
||||
|| a.querySelector('h3')?.textContent
|
||||
|| a.textContent?.trim()?.substring(0, 80)
|
||||
|| '';
|
||||
results.push({ title, url: href });
|
||||
}
|
||||
}
|
||||
const seen = new Set();
|
||||
return results.filter(r => {
|
||||
if (seen.has(r.url)) return false;
|
||||
seen.add(r.url);
|
||||
return true;
|
||||
}).slice(0, 5);
|
||||
}
|
||||
""");
|
||||
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
if (linksObj instanceof List<?> list) {
|
||||
for (var item : list) {
|
||||
if (item instanceof Map<?, ?> map) {
|
||||
results.add(Map.of(
|
||||
"title", String.valueOf(map.get("title")),
|
||||
"url", String.valueOf(map.get("url"))
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("[TABLING] Found {} results for '{}'", results.size(), restaurantName);
|
||||
return results;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Map<String, Object>> searchCatchtable(Page page, String restaurantName) {
|
||||
String query = "site:app.catchtable.co.kr " + restaurantName;
|
||||
log.info("[CATCHTABLE] Searching: {}", query);
|
||||
|
||||
String searchUrl = "https://www.google.com/search?q=" +
|
||||
URLEncoder.encode(query, StandardCharsets.UTF_8);
|
||||
page.navigate(searchUrl);
|
||||
page.waitForTimeout(3000);
|
||||
|
||||
Object linksObj = page.evaluate("""
|
||||
() => {
|
||||
const results = [];
|
||||
const links = document.querySelectorAll('a[href]');
|
||||
for (const a of links) {
|
||||
const href = a.href;
|
||||
if (href.includes('catchtable.co.kr/') && (href.includes('/dining/') || href.includes('/shop/'))) {
|
||||
const title = a.closest('[data-header-feature]')?.querySelector('h3')?.textContent
|
||||
|| a.querySelector('h3')?.textContent
|
||||
|| a.textContent?.trim()?.substring(0, 80)
|
||||
|| '';
|
||||
results.push({ title, url: href });
|
||||
}
|
||||
}
|
||||
const seen = new Set();
|
||||
return results.filter(r => {
|
||||
if (seen.has(r.url)) return false;
|
||||
seen.add(r.url);
|
||||
return true;
|
||||
}).slice(0, 5);
|
||||
}
|
||||
""");
|
||||
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
if (linksObj instanceof List<?> list) {
|
||||
for (var item : list) {
|
||||
if (item instanceof Map<?, ?> map) {
|
||||
results.add(Map.of(
|
||||
"title", String.valueOf(map.get("title")),
|
||||
"url", String.valueOf(map.get("url"))
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("[CATCHTABLE] Found {} results for '{}'", results.size(), restaurantName);
|
||||
return results;
|
||||
}
|
||||
|
||||
private void emit(SseEmitter emitter, Map<String, Object> data) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().data(objectMapper.writeValueAsString(data)));
|
||||
} catch (Exception e) {
|
||||
log.debug("SSE emit error: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.domain.Review;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.ReviewService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class ReviewController {
|
||||
|
||||
private final ReviewService reviewService;
|
||||
|
||||
public ReviewController(ReviewService reviewService) {
|
||||
this.reviewService = reviewService;
|
||||
}
|
||||
|
||||
@GetMapping("/restaurants/{restaurantId}/reviews")
|
||||
public Map<String, Object> listRestaurantReviews(
|
||||
@PathVariable String restaurantId,
|
||||
@RequestParam(defaultValue = "20") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset) {
|
||||
var reviews = reviewService.findByRestaurant(restaurantId, limit, offset);
|
||||
var stats = reviewService.getAvgRating(restaurantId);
|
||||
return Map.of("reviews", reviews, "avg_rating", stats.get("avg_rating"),
|
||||
"review_count", stats.get("review_count"));
|
||||
}
|
||||
|
||||
@PostMapping("/restaurants/{restaurantId}/reviews")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public Review createReview(
|
||||
@PathVariable String restaurantId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String userId = AuthUtil.getUserId();
|
||||
double rating = ((Number) body.get("rating")).doubleValue();
|
||||
String text = (String) body.get("review_text");
|
||||
LocalDate visitedAt = body.get("visited_at") != null
|
||||
? LocalDate.parse((String) body.get("visited_at")) : null;
|
||||
return reviewService.create(userId, restaurantId, rating, text, visitedAt);
|
||||
}
|
||||
|
||||
@PutMapping("/reviews/{reviewId}")
|
||||
public Map<String, Object> updateReview(
|
||||
@PathVariable String reviewId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String userId = AuthUtil.getUserId();
|
||||
Double rating = body.get("rating") != null
|
||||
? ((Number) body.get("rating")).doubleValue() : null;
|
||||
String text = (String) body.get("review_text");
|
||||
LocalDate visitedAt = body.get("visited_at") != null
|
||||
? LocalDate.parse((String) body.get("visited_at")) : null;
|
||||
if (!reviewService.update(reviewId, userId, rating, text, visitedAt)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Review not found or not yours");
|
||||
}
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/reviews/{reviewId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteReview(@PathVariable String reviewId) {
|
||||
String userId = AuthUtil.getUserId();
|
||||
if (!reviewService.delete(reviewId, userId)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Review not found or not yours");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/users/me/reviews")
|
||||
public List<Review> myReviews(
|
||||
@RequestParam(defaultValue = "20") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset) {
|
||||
return reviewService.findByUser(AuthUtil.getUserId(), limit, offset);
|
||||
}
|
||||
|
||||
// Favorites
|
||||
@GetMapping("/restaurants/{restaurantId}/favorite")
|
||||
public Map<String, Object> favoriteStatus(@PathVariable String restaurantId) {
|
||||
return Map.of("favorited", reviewService.isFavorited(AuthUtil.getUserId(), restaurantId));
|
||||
}
|
||||
|
||||
@PostMapping("/restaurants/{restaurantId}/favorite")
|
||||
public Map<String, Object> toggleFavorite(@PathVariable String restaurantId) {
|
||||
boolean result = reviewService.toggleFavorite(AuthUtil.getUserId(), restaurantId);
|
||||
return Map.of("favorited", result);
|
||||
}
|
||||
|
||||
@GetMapping("/users/me/favorites")
|
||||
public List<Restaurant> myFavorites() {
|
||||
return reviewService.getUserFavorites(AuthUtil.getUserId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.service.SearchService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/search")
|
||||
public class SearchController {
|
||||
|
||||
private final SearchService searchService;
|
||||
|
||||
public SearchController(SearchService searchService) {
|
||||
this.searchService = searchService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Restaurant> search(
|
||||
@RequestParam String q,
|
||||
@RequestParam(defaultValue = "keyword") String mode,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
if (limit > 100) limit = 100;
|
||||
return searchService.search(q, mode, limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.domain.SiteVisitStats;
|
||||
import com.tasteby.service.StatsService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/stats")
|
||||
public class StatsController {
|
||||
|
||||
private final StatsService statsService;
|
||||
|
||||
public StatsController(StatsService statsService) {
|
||||
this.statsService = statsService;
|
||||
}
|
||||
|
||||
@PostMapping("/visit")
|
||||
public Map<String, Object> recordVisit() {
|
||||
statsService.recordVisit();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@GetMapping("/visits")
|
||||
public SiteVisitStats getVisits() {
|
||||
return statsService.getVisits();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.domain.VideoDetail;
|
||||
import com.tasteby.domain.VideoSummary;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.*;
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/videos")
|
||||
public class VideoController {
|
||||
|
||||
private final VideoService videoService;
|
||||
private final CacheService cache;
|
||||
private final YouTubeService youTubeService;
|
||||
private final PipelineService pipelineService;
|
||||
private final ExtractorService extractorService;
|
||||
private final RestaurantService restaurantService;
|
||||
private final GeocodingService geocodingService;
|
||||
|
||||
public VideoController(VideoService videoService, CacheService cache,
|
||||
YouTubeService youTubeService, PipelineService pipelineService,
|
||||
ExtractorService extractorService, RestaurantService restaurantService,
|
||||
GeocodingService geocodingService) {
|
||||
this.videoService = videoService;
|
||||
this.cache = cache;
|
||||
this.youTubeService = youTubeService;
|
||||
this.pipelineService = pipelineService;
|
||||
this.extractorService = extractorService;
|
||||
this.restaurantService = restaurantService;
|
||||
this.geocodingService = geocodingService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<VideoSummary> list(@RequestParam(required = false) String status) {
|
||||
return videoService.findAll(status);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public VideoDetail detail(@PathVariable String id) {
|
||||
var video = videoService.findDetail(id);
|
||||
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
|
||||
return video;
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public Map<String, Object> updateTitle(@PathVariable String id, @RequestBody Map<String, String> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
String title = body.get("title");
|
||||
if (title == null || title.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "title is required");
|
||||
}
|
||||
videoService.updateTitle(id, title);
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/skip")
|
||||
public Map<String, Object> skip(@PathVariable String id) {
|
||||
AuthUtil.requireAdmin();
|
||||
videoService.updateStatus(id, "skip");
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public Map<String, Object> delete(@PathVariable String id) {
|
||||
AuthUtil.requireAdmin();
|
||||
videoService.delete(id);
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{videoId}/restaurants/{restaurantId}")
|
||||
public Map<String, Object> deleteVideoRestaurant(
|
||||
@PathVariable String videoId, @PathVariable String restaurantId) {
|
||||
AuthUtil.requireAdmin();
|
||||
videoService.deleteVideoRestaurant(videoId, restaurantId);
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/fetch-transcript")
|
||||
public Map<String, Object> fetchTranscript(@PathVariable String id,
|
||||
@RequestParam(defaultValue = "auto") String mode) {
|
||||
AuthUtil.requireAdmin();
|
||||
var video = videoService.findDetail(id);
|
||||
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
|
||||
|
||||
var result = youTubeService.getTranscript(video.getVideoId(), mode);
|
||||
if (result == null || result.text() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No transcript available");
|
||||
}
|
||||
|
||||
videoService.updateTranscript(id, result.text());
|
||||
return Map.of("ok", true, "length", result.text().length(), "source", result.source());
|
||||
}
|
||||
|
||||
/** 클라이언트(브라우저)에서 가져온 트랜스크립트를 저장 */
|
||||
@PostMapping("/{id}/upload-transcript")
|
||||
public Map<String, Object> uploadTranscript(@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
var video = videoService.findDetail(id);
|
||||
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
|
||||
|
||||
String text = body.get("text");
|
||||
if (text == null || text.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "text is required");
|
||||
}
|
||||
|
||||
videoService.updateTranscript(id, text);
|
||||
String source = body.getOrDefault("source", "browser");
|
||||
return Map.of("ok", true, "length", text.length(), "source", source);
|
||||
}
|
||||
|
||||
@GetMapping("/extract/prompt")
|
||||
public Map<String, Object> getExtractPrompt() {
|
||||
return Map.of("prompt", extractorService.getPrompt());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/extract")
|
||||
public Map<String, Object> extract(@PathVariable String id,
|
||||
@RequestBody(required = false) Map<String, String> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
var video = videoService.findDetail(id);
|
||||
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
|
||||
if (video.getTranscriptText() == null || video.getTranscriptText().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No transcript");
|
||||
}
|
||||
|
||||
String customPrompt = body != null ? body.get("prompt") : null;
|
||||
var videoMap = Map.<String, Object>of("id", id, "video_id", video.getVideoId(), "title", video.getTitle());
|
||||
int count = pipelineService.processExtract(videoMap, video.getTranscriptText(), customPrompt);
|
||||
if (count > 0) cache.flush();
|
||||
return Map.of("ok", true, "restaurants_extracted", count);
|
||||
}
|
||||
|
||||
@GetMapping("/bulk-extract/pending")
|
||||
public Map<String, Object> bulkExtractPending() {
|
||||
var videos = videoService.findVideosForBulkExtract();
|
||||
var summary = videos.stream().map(v -> Map.of("id", v.get("id"), "title", v.get("title"))).toList();
|
||||
return Map.of("count", videos.size(), "videos", summary);
|
||||
}
|
||||
|
||||
@GetMapping("/bulk-transcript/pending")
|
||||
public Map<String, Object> bulkTranscriptPending() {
|
||||
var videos = videoService.findVideosWithoutTranscript();
|
||||
var summary = videos.stream().map(v -> Map.of("id", v.get("id"), "title", v.get("title"))).toList();
|
||||
return Map.of("count", videos.size(), "videos", summary);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@PostMapping("/{videoId}/restaurants/manual")
|
||||
public Map<String, Object> addManualRestaurant(@PathVariable String videoId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
String name = (String) body.get("name");
|
||||
if (name == null || name.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "name is required");
|
||||
}
|
||||
|
||||
// Geocode
|
||||
var geo = geocodingService.geocodeRestaurant(name, (String) body.get("address"));
|
||||
var data = new HashMap<String, Object>();
|
||||
data.put("name", name);
|
||||
data.put("address", geo != null ? geo.get("formatted_address") : body.get("address"));
|
||||
data.put("region", body.get("region"));
|
||||
data.put("latitude", geo != null ? geo.get("latitude") : null);
|
||||
data.put("longitude", geo != null ? geo.get("longitude") : null);
|
||||
data.put("cuisine_type", body.get("cuisine_type"));
|
||||
data.put("price_range", body.get("price_range"));
|
||||
data.put("google_place_id", geo != null ? geo.get("google_place_id") : null);
|
||||
data.put("phone", geo != null ? geo.get("phone") : null);
|
||||
data.put("website", geo != null ? geo.get("website") : null);
|
||||
data.put("business_status", geo != null ? geo.get("business_status") : null);
|
||||
data.put("rating", geo != null ? geo.get("rating") : null);
|
||||
data.put("rating_count", geo != null ? geo.get("rating_count") : null);
|
||||
|
||||
String restId = restaurantService.upsert(data);
|
||||
|
||||
// Parse foods and guests
|
||||
List<String> foods = null;
|
||||
Object foodsRaw = body.get("foods_mentioned");
|
||||
if (foodsRaw instanceof List<?>) {
|
||||
foods = ((List<?>) foodsRaw).stream().map(Object::toString).toList();
|
||||
} else if (foodsRaw instanceof String s && !s.isBlank()) {
|
||||
foods = List.of(s.split("\\s*,\\s*"));
|
||||
}
|
||||
|
||||
List<String> guests = null;
|
||||
Object guestsRaw = body.get("guests");
|
||||
if (guestsRaw instanceof List<?>) {
|
||||
guests = ((List<?>) guestsRaw).stream().map(Object::toString).toList();
|
||||
} else if (guestsRaw instanceof String s && !s.isBlank()) {
|
||||
guests = List.of(s.split("\\s*,\\s*"));
|
||||
}
|
||||
|
||||
String evaluation = body.get("evaluation") instanceof String s ? s : null;
|
||||
|
||||
restaurantService.linkVideoRestaurant(videoId, restId, foods, evaluation, guests);
|
||||
cache.flush();
|
||||
return Map.of("ok", true, "restaurant_id", restId);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@PutMapping("/{videoId}/restaurants/{restaurantId}")
|
||||
public Map<String, Object> updateVideoRestaurant(@PathVariable String videoId,
|
||||
@PathVariable String restaurantId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
|
||||
// Update link fields (foods_mentioned, evaluation, guests)
|
||||
List<String> foods = null;
|
||||
Object foodsRaw = body.get("foods_mentioned");
|
||||
if (foodsRaw instanceof List<?>) {
|
||||
foods = ((List<?>) foodsRaw).stream().map(Object::toString).toList();
|
||||
} else if (foodsRaw instanceof String s && !s.isBlank()) {
|
||||
foods = List.of(s.split("\\s*,\\s*"));
|
||||
}
|
||||
|
||||
List<String> guests = null;
|
||||
Object guestsRaw = body.get("guests");
|
||||
if (guestsRaw instanceof List<?>) {
|
||||
guests = ((List<?>) guestsRaw).stream().map(Object::toString).toList();
|
||||
} else if (guestsRaw instanceof String s && !s.isBlank()) {
|
||||
guests = List.of(s.split("\\s*,\\s*"));
|
||||
}
|
||||
|
||||
// evaluation must be valid JSON for DB IS JSON constraint
|
||||
String evaluationJson = null;
|
||||
Object evalRaw = body.get("evaluation");
|
||||
if (evalRaw instanceof String s && !s.isBlank()) {
|
||||
evaluationJson = s.trim().startsWith("{") || s.trim().startsWith("\"") ? s : JsonUtil.toJson(s);
|
||||
} else if (evalRaw instanceof Map<?, ?>) {
|
||||
evaluationJson = JsonUtil.toJson(evalRaw);
|
||||
}
|
||||
String foodsJson = foods != null ? JsonUtil.toJson(foods) : null;
|
||||
String guestsJson = guests != null ? JsonUtil.toJson(guests) : null;
|
||||
videoService.updateVideoRestaurantFields(videoId, restaurantId, foodsJson, evaluationJson, guestsJson);
|
||||
|
||||
// Update restaurant fields if provided
|
||||
var restFields = new HashMap<String, Object>();
|
||||
for (var key : List.of("name", "address", "region", "cuisine_type", "price_range")) {
|
||||
if (body.containsKey(key)) restFields.put(key, body.get(key));
|
||||
}
|
||||
if (!restFields.isEmpty()) {
|
||||
// Re-geocode if name or address changed
|
||||
var existing = restaurantService.findById(restaurantId);
|
||||
String newName = (String) restFields.get("name");
|
||||
String newAddr = (String) restFields.get("address");
|
||||
boolean nameChanged = newName != null && existing != null && !newName.equals(existing.getName());
|
||||
boolean addrChanged = newAddr != null && existing != null && !newAddr.equals(existing.getAddress());
|
||||
if (nameChanged || addrChanged) {
|
||||
String geoName = newName != null ? newName : existing.getName();
|
||||
String geoAddr = newAddr != null ? newAddr : existing.getAddress();
|
||||
var geo = geocodingService.geocodeRestaurant(geoName, geoAddr);
|
||||
if (geo != null) {
|
||||
restFields.put("latitude", geo.get("latitude"));
|
||||
restFields.put("longitude", geo.get("longitude"));
|
||||
restFields.put("google_place_id", geo.get("google_place_id"));
|
||||
if (geo.containsKey("formatted_address")) {
|
||||
restFields.put("address", geo.get("formatted_address"));
|
||||
}
|
||||
if (geo.containsKey("rating")) restFields.put("rating", geo.get("rating"));
|
||||
if (geo.containsKey("rating_count")) restFields.put("rating_count", geo.get("rating_count"));
|
||||
if (geo.containsKey("phone")) restFields.put("phone", geo.get("phone"));
|
||||
if (geo.containsKey("business_status")) restFields.put("business_status", geo.get("business_status"));
|
||||
// Parse region from address
|
||||
String addr = (String) geo.get("formatted_address");
|
||||
if (addr != null) {
|
||||
restFields.put("region", GeocodingService.parseRegionFromAddress(addr));
|
||||
}
|
||||
}
|
||||
}
|
||||
restaurantService.update(restaurantId, restFields);
|
||||
}
|
||||
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.*;
|
||||
import com.tasteby.util.CuisineTypes;
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
/**
|
||||
* SSE streaming endpoints for bulk operations.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/videos")
|
||||
public class VideoSseController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(VideoSseController.class);
|
||||
|
||||
private final VideoService videoService;
|
||||
private final RestaurantService restaurantService;
|
||||
private final PipelineService pipelineService;
|
||||
private final YouTubeService youTubeService;
|
||||
private final OciGenAiService genAi;
|
||||
private final CacheService cache;
|
||||
private final ObjectMapper mapper;
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
public VideoSseController(VideoService videoService,
|
||||
RestaurantService restaurantService,
|
||||
PipelineService pipelineService,
|
||||
YouTubeService youTubeService,
|
||||
OciGenAiService genAi,
|
||||
CacheService cache,
|
||||
ObjectMapper mapper) {
|
||||
this.videoService = videoService;
|
||||
this.restaurantService = restaurantService;
|
||||
this.pipelineService = pipelineService;
|
||||
this.youTubeService = youTubeService;
|
||||
this.genAi = genAi;
|
||||
this.cache = cache;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@PostMapping("/bulk-transcript")
|
||||
public SseEmitter bulkTranscript(@RequestBody(required = false) Map<String, Object> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
SseEmitter emitter = new SseEmitter(1_800_000L); // 30 min timeout
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> selectedIds = body != null && body.containsKey("ids")
|
||||
? ((List<?>) body.get("ids")).stream().map(Object::toString).toList()
|
||||
: null;
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
var videos = selectedIds != null && !selectedIds.isEmpty()
|
||||
? videoService.findVideosByIds(selectedIds)
|
||||
: videoService.findVideosWithoutTranscript();
|
||||
int total = videos.size();
|
||||
emit(emitter, Map.of("type", "start", "total", total));
|
||||
|
||||
if (total == 0) {
|
||||
emit(emitter, Map.of("type", "complete", "total", 0, "success", 0, "failed", 0));
|
||||
emitter.complete();
|
||||
return;
|
||||
}
|
||||
|
||||
int success = 0;
|
||||
int failed = 0;
|
||||
|
||||
// Pass 1: 브라우저 우선 (봇 탐지 회피)
|
||||
var apiNeeded = new ArrayList<Integer>();
|
||||
try (var session = youTubeService.createBrowserSession()) {
|
||||
for (int i = 0; i < total; i++) {
|
||||
var v = videos.get(i);
|
||||
String videoId = (String) v.get("video_id");
|
||||
String title = (String) v.get("title");
|
||||
String id = (String) v.get("id");
|
||||
|
||||
emit(emitter, Map.of("type", "processing", "index", i, "title", title, "method", "browser"));
|
||||
|
||||
try {
|
||||
var result = youTubeService.getTranscriptWithPage(session.page(), videoId);
|
||||
if (result != null) {
|
||||
videoService.updateTranscript(id, result.text());
|
||||
success++;
|
||||
emit(emitter, Map.of("type", "done", "index", i,
|
||||
"title", title, "source", result.source(),
|
||||
"length", result.text().length()));
|
||||
} else {
|
||||
apiNeeded.add(i);
|
||||
emit(emitter, Map.of("type", "skip", "index", i,
|
||||
"title", title, "message", "브라우저 실패, API로 재시도 예정"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
apiNeeded.add(i);
|
||||
log.warn("[BULK-TRANSCRIPT] Browser failed for {}: {}", videoId, e.getMessage());
|
||||
}
|
||||
|
||||
// 봇 판정 방지 랜덤 딜레이 (3~8초)
|
||||
if (i < total - 1) {
|
||||
int delay = ThreadLocalRandom.current().nextInt(3000, 8001);
|
||||
log.info("[BULK-TRANSCRIPT] Waiting {}ms before next...", delay);
|
||||
session.page().waitForTimeout(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: 브라우저 실패분만 API로 재시도
|
||||
if (!apiNeeded.isEmpty()) {
|
||||
emit(emitter, Map.of("type", "api_pass", "count", apiNeeded.size()));
|
||||
for (int i : apiNeeded) {
|
||||
var v = videos.get(i);
|
||||
String videoId = (String) v.get("video_id");
|
||||
String title = (String) v.get("title");
|
||||
String id = (String) v.get("id");
|
||||
|
||||
emit(emitter, Map.of("type", "processing", "index", i, "title", title, "method", "api"));
|
||||
|
||||
try {
|
||||
var result = youTubeService.getTranscriptApi(videoId, "auto");
|
||||
if (result != null) {
|
||||
videoService.updateTranscript(id, result.text());
|
||||
success++;
|
||||
emit(emitter, Map.of("type", "done", "index", i,
|
||||
"title", title, "source", result.source(),
|
||||
"length", result.text().length()));
|
||||
} else {
|
||||
failed++;
|
||||
videoService.updateStatus(id, "no_transcript");
|
||||
emit(emitter, Map.of("type", "error", "index", i,
|
||||
"title", title, "message", "자막을 찾을 수 없음"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
failed++;
|
||||
videoService.updateStatus(id, "no_transcript");
|
||||
log.error("[BULK-TRANSCRIPT] API error for {}: {}", videoId, e.getMessage());
|
||||
emit(emitter, Map.of("type", "error", "index", i,
|
||||
"title", title, "message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit(emitter, Map.of("type", "complete", "total", total, "success", success, "failed", failed));
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
log.error("Bulk transcript error", e);
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
return emitter;
|
||||
}
|
||||
|
||||
@PostMapping("/bulk-extract")
|
||||
public SseEmitter bulkExtract(@RequestBody(required = false) Map<String, Object> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
SseEmitter emitter = new SseEmitter(600_000L);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> selectedIds = body != null && body.containsKey("ids")
|
||||
? ((List<?>) body.get("ids")).stream().map(Object::toString).toList()
|
||||
: null;
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
var rows = selectedIds != null && !selectedIds.isEmpty()
|
||||
? videoService.findVideosForExtractByIds(selectedIds)
|
||||
: videoService.findVideosForBulkExtract();
|
||||
|
||||
int total = rows.size();
|
||||
int totalRestaurants = 0;
|
||||
emit(emitter, Map.of("type", "start", "total", total));
|
||||
|
||||
for (int i = 0; i < total; i++) {
|
||||
var v = rows.get(i);
|
||||
if (i > 0) {
|
||||
long delay = (long) (3000 + Math.random() * 5000);
|
||||
emit(emitter, Map.of("type", "wait", "index", i, "delay", delay / 1000.0));
|
||||
Thread.sleep(delay);
|
||||
}
|
||||
emit(emitter, Map.of("type", "processing", "index", i, "title", v.get("title")));
|
||||
try {
|
||||
int count = pipelineService.processExtract(v, (String) v.get("transcript"), null);
|
||||
totalRestaurants += count;
|
||||
emit(emitter, Map.of("type", "done", "index", i, "title", v.get("title"), "restaurants", count));
|
||||
} catch (Exception e) {
|
||||
log.error("Bulk extract error for {}: {}", v.get("video_id"), e.getMessage());
|
||||
emit(emitter, Map.of("type", "error", "index", i, "title", v.get("title"), "message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
if (totalRestaurants > 0) cache.flush();
|
||||
emit(emitter, Map.of("type", "complete", "total", total, "total_restaurants", totalRestaurants));
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
log.error("Bulk extract error", e);
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
return emitter;
|
||||
}
|
||||
|
||||
@PostMapping("/remap-cuisine")
|
||||
@SuppressWarnings("unchecked")
|
||||
public SseEmitter remapCuisine() {
|
||||
AuthUtil.requireAdmin();
|
||||
SseEmitter emitter = new SseEmitter(600_000L);
|
||||
int BATCH = 20;
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
var rows = restaurantService.findForRemapCuisine();
|
||||
rows = rows.stream().map(JsonUtil::lowerKeys).toList();
|
||||
|
||||
int total = rows.size();
|
||||
emit(emitter, Map.of("type", "start", "total", total));
|
||||
int updated = 0;
|
||||
var allMissed = new ArrayList<Map<String, Object>>();
|
||||
|
||||
// Pass 1
|
||||
for (int i = 0; i < total; i += BATCH) {
|
||||
var batch = rows.subList(i, Math.min(i + BATCH, total));
|
||||
emit(emitter, Map.of("type", "processing", "current", Math.min(i + BATCH, total), "total", total, "pass", 1));
|
||||
try {
|
||||
var result = applyRemapBatch(batch);
|
||||
updated += result.updated;
|
||||
allMissed.addAll(result.missed);
|
||||
emit(emitter, Map.of("type", "batch_done", "current", Math.min(i + BATCH, total), "total", total, "updated", updated, "missed", allMissed.size()));
|
||||
} catch (Exception e) {
|
||||
allMissed.addAll(batch);
|
||||
emit(emitter, Map.of("type", "error", "message", e.getMessage(), "current", i));
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: retry missed (up to 3 attempts with smaller batches)
|
||||
if (!allMissed.isEmpty()) {
|
||||
emit(emitter, Map.of("type", "retry", "missed", allMissed.size()));
|
||||
for (int attempt = 0; attempt < 3 && !allMissed.isEmpty(); attempt++) {
|
||||
var retryList = new ArrayList<>(allMissed);
|
||||
allMissed.clear();
|
||||
for (int i = 0; i < retryList.size(); i += 5) {
|
||||
var batch = retryList.subList(i, Math.min(i + 5, retryList.size()));
|
||||
try {
|
||||
var result = applyRemapBatch(batch);
|
||||
updated += result.updated;
|
||||
allMissed.addAll(result.missed);
|
||||
} catch (Exception e) {
|
||||
log.warn("Remap cuisine retry failed (attempt {}): {}", attempt + 1, e.getMessage());
|
||||
allMissed.addAll(batch);
|
||||
}
|
||||
}
|
||||
if (!allMissed.isEmpty()) {
|
||||
emit(emitter, Map.of("type", "retry", "attempt", attempt + 2, "missed", allMissed.size()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cache.flush();
|
||||
emit(emitter, Map.of("type", "complete", "total", total, "updated", updated, "missed", allMissed.size()));
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
return emitter;
|
||||
}
|
||||
|
||||
@PostMapping("/remap-foods")
|
||||
@SuppressWarnings("unchecked")
|
||||
public SseEmitter remapFoods() {
|
||||
AuthUtil.requireAdmin();
|
||||
SseEmitter emitter = new SseEmitter(600_000L);
|
||||
int BATCH = 15;
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
var rows = restaurantService.findForRemapFoods();
|
||||
rows = rows.stream().map(r -> {
|
||||
var m = JsonUtil.lowerKeys(r);
|
||||
// foods_mentioned is now TO_CHAR'd in SQL, parse as string
|
||||
Object fm = m.get("foods_mentioned");
|
||||
m.put("foods", JsonUtil.parseStringList(fm));
|
||||
return m;
|
||||
}).toList();
|
||||
|
||||
int total = rows.size();
|
||||
emit(emitter, Map.of("type", "start", "total", total));
|
||||
int updated = 0;
|
||||
var allMissed = new ArrayList<Map<String, Object>>();
|
||||
|
||||
for (int i = 0; i < total; i += BATCH) {
|
||||
var batch = rows.subList(i, Math.min(i + BATCH, total));
|
||||
emit(emitter, Map.of("type", "processing", "current", Math.min(i + BATCH, total), "total", total));
|
||||
try {
|
||||
var result = applyFoodsBatch(batch);
|
||||
updated += result.updated;
|
||||
allMissed.addAll(result.missed);
|
||||
emit(emitter, Map.of("type", "batch_done", "current", Math.min(i + BATCH, total), "total", total, "updated", updated));
|
||||
} catch (Exception e) {
|
||||
allMissed.addAll(batch);
|
||||
log.warn("Remap foods batch error at {}: {}", i, e.getMessage());
|
||||
emit(emitter, Map.of("type", "error", "message", e.getMessage(), "current", i));
|
||||
}
|
||||
}
|
||||
|
||||
// Retry missed (up to 3 attempts with smaller batches)
|
||||
if (!allMissed.isEmpty()) {
|
||||
emit(emitter, Map.of("type", "retry", "missed", allMissed.size()));
|
||||
for (int attempt = 0; attempt < 3 && !allMissed.isEmpty(); attempt++) {
|
||||
var retryList = new ArrayList<>(allMissed);
|
||||
allMissed.clear();
|
||||
for (int i = 0; i < retryList.size(); i += 5) {
|
||||
var batch = retryList.subList(i, Math.min(i + 5, retryList.size()));
|
||||
try {
|
||||
var r = applyFoodsBatch(batch);
|
||||
updated += r.updated;
|
||||
allMissed.addAll(r.missed);
|
||||
} catch (Exception e) {
|
||||
log.warn("Remap foods retry failed (attempt {}): {}", attempt + 1, e.getMessage());
|
||||
allMissed.addAll(batch);
|
||||
}
|
||||
}
|
||||
if (!allMissed.isEmpty()) {
|
||||
emit(emitter, Map.of("type", "retry", "attempt", attempt + 2, "missed", allMissed.size()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cache.flush();
|
||||
emit(emitter, Map.of("type", "complete", "total", total, "updated", updated, "missed", allMissed.size()));
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
return emitter;
|
||||
}
|
||||
|
||||
@PostMapping("/rebuild-vectors")
|
||||
public SseEmitter rebuildVectors() {
|
||||
AuthUtil.requireAdmin();
|
||||
SseEmitter emitter = new SseEmitter(600_000L);
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
emit(emitter, Map.of("type", "start"));
|
||||
// TODO: Implement full vector rebuild using VectorService
|
||||
emit(emitter, Map.of("type", "complete", "total", 0));
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
});
|
||||
return emitter;
|
||||
}
|
||||
|
||||
@PostMapping("/process")
|
||||
public Map<String, Object> process(@RequestParam(defaultValue = "5") int limit) {
|
||||
AuthUtil.requireAdmin();
|
||||
int count = pipelineService.processPending(limit);
|
||||
if (count > 0) cache.flush();
|
||||
return Map.of("restaurants_extracted", count);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private record BatchResult(int updated, List<Map<String, Object>> missed) {}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private BatchResult applyRemapBatch(List<Map<String, Object>> batch) throws Exception {
|
||||
var items = batch.stream().map(b -> Map.of(
|
||||
"id", b.get("id"), "name", b.get("name"),
|
||||
"current_cuisine_type", b.get("cuisine_type"),
|
||||
"foods_mentioned", b.get("foods_mentioned")
|
||||
)).toList();
|
||||
|
||||
String prompt = """
|
||||
아래 식당들의 cuisine_type을 표준 분류로 매핑하세요.
|
||||
|
||||
표준 분류 목록 (반드시 이 중 하나를 선택):
|
||||
%s
|
||||
|
||||
식당 목록:
|
||||
%s
|
||||
|
||||
규칙:
|
||||
- 모든 식당에 대해 빠짐없이 결과를 반환 (총 %d개 모두 반환해야 함)
|
||||
- 반드시 위 표준 분류 목록의 값을 그대로 복사하여 사용 (오타 금지)
|
||||
- JSON 배열만 반환, 설명 없음
|
||||
- 형식: [{"id": "식당ID", "cuisine_type": "한식|국밥/해장국"}, ...]
|
||||
|
||||
JSON 배열:""".formatted(CuisineTypes.CUISINE_LIST_TEXT, mapper.writeValueAsString(items), items.size());
|
||||
|
||||
String raw = genAi.chat(prompt, 4096);
|
||||
Object parsed = genAi.parseJson(raw);
|
||||
List<Map<String, Object>> results = parsed instanceof List<?> ? (List<Map<String, Object>>) parsed : List.of();
|
||||
|
||||
Map<String, String> resultMap = new HashMap<>();
|
||||
for (var item : results) {
|
||||
String id = (String) item.get("id");
|
||||
String type = (String) item.get("cuisine_type");
|
||||
if (id != null && type != null) resultMap.put(id, type);
|
||||
}
|
||||
|
||||
int updated = 0;
|
||||
var missed = new ArrayList<Map<String, Object>>();
|
||||
for (var b : batch) {
|
||||
String id = (String) b.get("id");
|
||||
String newType = resultMap.get(id);
|
||||
if (newType == null || !CuisineTypes.isValid(newType)) {
|
||||
missed.add(b);
|
||||
continue;
|
||||
}
|
||||
restaurantService.updateCuisineType(id, newType);
|
||||
updated++;
|
||||
}
|
||||
return new BatchResult(updated, missed);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private BatchResult applyFoodsBatch(List<Map<String, Object>> batch) throws Exception {
|
||||
var items = batch.stream().map(b -> Map.of(
|
||||
"id", b.get("id"), "name", b.get("name"),
|
||||
"current_foods", b.get("foods"), "cuisine_type", b.get("cuisine_type")
|
||||
)).toList();
|
||||
|
||||
String prompt = """
|
||||
아래 식당들의 대표 메뉴 태그를 다시 만들어주세요.
|
||||
|
||||
규칙:
|
||||
- 반드시 한글로 작성
|
||||
- 각 식당당 최대 10개의 대표 메뉴/음식 태그
|
||||
- 우선순위: 시그니처 메뉴 > 자주 언급된 메뉴 > 일반 메뉴
|
||||
- 너무 일반적인 태그(밥, 반찬 등)는 제외
|
||||
- 모든 식당에 대해 빠짐없이 결과 반환 (총 %d개)
|
||||
- JSON 배열만 반환, 설명 없음
|
||||
- 형식: [{"id": "식당ID", "foods": ["메뉴1", "메뉴2", ...]}]
|
||||
|
||||
식당 목록:
|
||||
%s
|
||||
|
||||
JSON 배열:""".formatted(items.size(), mapper.writeValueAsString(items));
|
||||
|
||||
String raw = genAi.chat(prompt, 4096);
|
||||
Object parsed = genAi.parseJson(raw);
|
||||
List<Map<String, Object>> results = parsed instanceof List<?> ? (List<Map<String, Object>>) parsed : List.of();
|
||||
|
||||
Map<String, List<String>> resultMap = new HashMap<>();
|
||||
for (var item : results) {
|
||||
String id = (String) item.get("id");
|
||||
Object foods = item.get("foods");
|
||||
if (id != null && foods instanceof List<?> list) {
|
||||
resultMap.put(id, list.stream().map(Object::toString).limit(10).toList());
|
||||
}
|
||||
}
|
||||
|
||||
int updated = 0;
|
||||
var missed = new ArrayList<Map<String, Object>>();
|
||||
for (var b : batch) {
|
||||
String id = (String) b.get("id");
|
||||
List<String> newFoods = resultMap.get(id);
|
||||
if (newFoods == null) {
|
||||
missed.add(b);
|
||||
continue;
|
||||
}
|
||||
restaurantService.updateFoodsMentioned(id, mapper.writeValueAsString(newFoods));
|
||||
updated++;
|
||||
}
|
||||
return new BatchResult(updated, missed);
|
||||
}
|
||||
|
||||
private void emit(SseEmitter emitter, Map<String, Object> data) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().data(mapper.writeValueAsString(data)));
|
||||
} catch (Exception e) {
|
||||
log.debug("SSE emit failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
19
backend-java/src/main/java/com/tasteby/domain/Channel.java
Normal file
19
backend-java/src/main/java/com/tasteby/domain/Channel.java
Normal file
@@ -0,0 +1,19 @@
|
||||
package com.tasteby.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Channel {
|
||||
private String id;
|
||||
private String channelId;
|
||||
private String channelName;
|
||||
private String titleFilter;
|
||||
private int videoCount;
|
||||
private String lastVideoAt;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.tasteby.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DaemonConfig {
|
||||
private int id;
|
||||
private boolean scanEnabled;
|
||||
private int scanIntervalMin;
|
||||
private boolean processEnabled;
|
||||
private int processIntervalMin;
|
||||
private int processLimit;
|
||||
private Date lastScanAt;
|
||||
private Date lastProcessAt;
|
||||
private Date updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.tasteby.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Restaurant {
|
||||
private String id;
|
||||
private String name;
|
||||
private String address;
|
||||
private String region;
|
||||
private Double latitude;
|
||||
private Double longitude;
|
||||
private String cuisineType;
|
||||
private String priceRange;
|
||||
private String phone;
|
||||
private String website;
|
||||
private String googlePlaceId;
|
||||
private String tablingUrl;
|
||||
private String catchtableUrl;
|
||||
private String businessStatus;
|
||||
private Double rating;
|
||||
private Integer ratingCount;
|
||||
private Date updatedAt;
|
||||
|
||||
// Transient enrichment fields
|
||||
private List<String> channels;
|
||||
private List<String> foodsMentioned;
|
||||
}
|
||||
24
backend-java/src/main/java/com/tasteby/domain/Review.java
Normal file
24
backend-java/src/main/java/com/tasteby/domain/Review.java
Normal file
@@ -0,0 +1,24 @@
|
||||
package com.tasteby.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Review {
|
||||
private String id;
|
||||
private String userId;
|
||||
private String restaurantId;
|
||||
private double rating;
|
||||
private String reviewText;
|
||||
private String visitedAt;
|
||||
private String createdAt;
|
||||
private String updatedAt;
|
||||
private String userNickname;
|
||||
private String userAvatarUrl;
|
||||
private String restaurantName;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.tasteby.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SiteVisitStats {
|
||||
private int today;
|
||||
private int total;
|
||||
}
|
||||
25
backend-java/src/main/java/com/tasteby/domain/UserInfo.java
Normal file
25
backend-java/src/main/java/com/tasteby/domain/UserInfo.java
Normal file
@@ -0,0 +1,25 @@
|
||||
package com.tasteby.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UserInfo {
|
||||
private String id;
|
||||
private String email;
|
||||
private String nickname;
|
||||
private String avatarUrl;
|
||||
@JsonProperty("is_admin")
|
||||
private boolean admin;
|
||||
private String provider;
|
||||
private String providerId;
|
||||
private String createdAt;
|
||||
private int favoriteCount;
|
||||
private int reviewCount;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.tasteby.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class VectorSearchResult {
|
||||
private String restaurantId;
|
||||
private String chunkText;
|
||||
private double distance;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.tasteby.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class VideoDetail {
|
||||
private String id;
|
||||
private String videoId;
|
||||
private String title;
|
||||
private String url;
|
||||
private String status;
|
||||
private String publishedAt;
|
||||
private String channelName;
|
||||
private boolean hasTranscript;
|
||||
private boolean hasLlm;
|
||||
private int restaurantCount;
|
||||
private int matchedCount;
|
||||
@JsonProperty("transcript")
|
||||
private String transcriptText;
|
||||
private List<VideoRestaurantLink> restaurants;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.tasteby.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonRawValue;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class VideoRestaurantLink {
|
||||
private String restaurantId;
|
||||
private String name;
|
||||
private String address;
|
||||
private String cuisineType;
|
||||
private String priceRange;
|
||||
private String region;
|
||||
@JsonRawValue
|
||||
private String foodsMentioned;
|
||||
@JsonRawValue
|
||||
private String evaluation;
|
||||
@JsonRawValue
|
||||
private String guests;
|
||||
private String googlePlaceId;
|
||||
private Double latitude;
|
||||
private Double longitude;
|
||||
|
||||
public boolean isHasLocation() {
|
||||
return latitude != null && longitude != null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.tasteby.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class VideoSummary {
|
||||
private String id;
|
||||
private String videoId;
|
||||
private String title;
|
||||
private String url;
|
||||
private String status;
|
||||
private String publishedAt;
|
||||
private String channelName;
|
||||
private boolean hasTranscript;
|
||||
private boolean hasLlm;
|
||||
private int restaurantCount;
|
||||
private int matchedCount;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.tasteby.mapper;
|
||||
|
||||
import com.tasteby.domain.Channel;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ChannelMapper {
|
||||
|
||||
List<Channel> findAllActive();
|
||||
|
||||
void insert(@Param("id") String id,
|
||||
@Param("channelId") String channelId,
|
||||
@Param("channelName") String channelName,
|
||||
@Param("titleFilter") String titleFilter);
|
||||
|
||||
int deactivateByChannelId(@Param("channelId") String channelId);
|
||||
|
||||
int deactivateById(@Param("id") String id);
|
||||
|
||||
Channel findByChannelId(@Param("channelId") String channelId);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.tasteby.mapper;
|
||||
|
||||
import com.tasteby.domain.DaemonConfig;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface DaemonConfigMapper {
|
||||
|
||||
DaemonConfig getConfig();
|
||||
|
||||
void updateConfig(DaemonConfig config);
|
||||
|
||||
void updateLastScan();
|
||||
|
||||
void updateLastProcess();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.tasteby.mapper;
|
||||
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Mapper
|
||||
public interface RestaurantMapper {
|
||||
|
||||
List<Restaurant> findAll(@Param("limit") int limit,
|
||||
@Param("offset") int offset,
|
||||
@Param("cuisine") String cuisine,
|
||||
@Param("region") String region,
|
||||
@Param("channel") String channel);
|
||||
|
||||
Restaurant findById(@Param("id") String id);
|
||||
|
||||
List<Map<String, Object>> findVideoLinks(@Param("restaurantId") String restaurantId);
|
||||
|
||||
void insertRestaurant(Restaurant r);
|
||||
|
||||
void updateRestaurant(Restaurant r);
|
||||
|
||||
void updateFields(@Param("id") String id, @Param("fields") Map<String, Object> fields);
|
||||
|
||||
void deleteVectors(@Param("id") String id);
|
||||
|
||||
void deleteReviews(@Param("id") String id);
|
||||
|
||||
void deleteFavorites(@Param("id") String id);
|
||||
|
||||
void deleteVideoRestaurants(@Param("id") String id);
|
||||
|
||||
void deleteRestaurant(@Param("id") String id);
|
||||
|
||||
void linkVideoRestaurant(@Param("id") String id,
|
||||
@Param("videoId") String videoId,
|
||||
@Param("restaurantId") String restaurantId,
|
||||
@Param("foods") String foods,
|
||||
@Param("evaluation") String evaluation,
|
||||
@Param("guests") String guests);
|
||||
|
||||
String findIdByPlaceId(@Param("placeId") String placeId);
|
||||
|
||||
String findIdByName(@Param("name") String name);
|
||||
|
||||
List<Map<String, Object>> findChannelsByRestaurantIds(@Param("ids") List<String> ids);
|
||||
|
||||
List<Map<String, Object>> findFoodsByRestaurantIds(@Param("ids") List<String> ids);
|
||||
|
||||
void updateCuisineType(@Param("id") String id, @Param("cuisineType") String cuisineType);
|
||||
|
||||
void updateFoodsMentioned(@Param("id") String id, @Param("foods") String foods);
|
||||
|
||||
List<Restaurant> findWithoutTabling();
|
||||
|
||||
List<Restaurant> findWithoutCatchtable();
|
||||
|
||||
List<Map<String, Object>> findForRemapCuisine();
|
||||
|
||||
List<Map<String, Object>> findForRemapFoods();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.tasteby.mapper;
|
||||
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.domain.Review;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Mapper
|
||||
public interface ReviewMapper {
|
||||
|
||||
void insertReview(@Param("id") String id,
|
||||
@Param("userId") String userId,
|
||||
@Param("restaurantId") String restaurantId,
|
||||
@Param("rating") double rating,
|
||||
@Param("reviewText") String reviewText,
|
||||
@Param("visitedAt") String visitedAt);
|
||||
|
||||
int updateReview(@Param("id") String id,
|
||||
@Param("userId") String userId,
|
||||
@Param("rating") Double rating,
|
||||
@Param("reviewText") String reviewText,
|
||||
@Param("visitedAt") String visitedAt);
|
||||
|
||||
int deleteReview(@Param("id") String id, @Param("userId") String userId);
|
||||
|
||||
Review findById(@Param("id") String id);
|
||||
|
||||
List<Review> findByRestaurant(@Param("restaurantId") String restaurantId,
|
||||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
Map<String, Object> getAvgRating(@Param("restaurantId") String restaurantId);
|
||||
|
||||
List<Review> findByUser(@Param("userId") String userId,
|
||||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
int countFavorite(@Param("userId") String userId, @Param("restaurantId") String restaurantId);
|
||||
|
||||
void insertFavorite(@Param("id") String id,
|
||||
@Param("userId") String userId,
|
||||
@Param("restaurantId") String restaurantId);
|
||||
|
||||
int deleteFavorite(@Param("userId") String userId, @Param("restaurantId") String restaurantId);
|
||||
|
||||
String findFavoriteId(@Param("userId") String userId, @Param("restaurantId") String restaurantId);
|
||||
|
||||
List<Restaurant> getUserFavorites(@Param("userId") String userId);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.tasteby.mapper;
|
||||
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Mapper
|
||||
public interface SearchMapper {
|
||||
|
||||
List<Restaurant> keywordSearch(@Param("query") String query, @Param("limit") int limit);
|
||||
|
||||
List<Map<String, Object>> findChannelsByRestaurantIds(@Param("ids") List<String> ids);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.tasteby.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface StatsMapper {
|
||||
|
||||
void recordVisit();
|
||||
|
||||
int getTodayVisits();
|
||||
|
||||
int getTotalVisits();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.tasteby.mapper;
|
||||
|
||||
import com.tasteby.domain.UserInfo;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface UserMapper {
|
||||
|
||||
UserInfo findByProviderAndProviderId(@Param("provider") String provider,
|
||||
@Param("providerId") String providerId);
|
||||
|
||||
void updateLastLogin(@Param("id") String id);
|
||||
|
||||
void insert(UserInfo user);
|
||||
|
||||
UserInfo findById(@Param("id") String id);
|
||||
|
||||
List<UserInfo> findAllWithCounts(@Param("limit") int limit, @Param("offset") int offset);
|
||||
|
||||
int countAll();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.tasteby.mapper;
|
||||
|
||||
import com.tasteby.domain.VectorSearchResult;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface VectorMapper {
|
||||
|
||||
List<VectorSearchResult> searchSimilar(@Param("queryVec") String queryVec,
|
||||
@Param("topK") int topK,
|
||||
@Param("maxDistance") double maxDistance);
|
||||
|
||||
void insertVector(@Param("id") String id,
|
||||
@Param("restaurantId") String restaurantId,
|
||||
@Param("chunkText") String chunkText,
|
||||
@Param("embedding") String embedding);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.tasteby.mapper;
|
||||
|
||||
import com.tasteby.domain.VideoDetail;
|
||||
import com.tasteby.domain.VideoRestaurantLink;
|
||||
import com.tasteby.domain.VideoSummary;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Mapper
|
||||
public interface VideoMapper {
|
||||
|
||||
List<VideoSummary> findAll(@Param("status") String status);
|
||||
|
||||
VideoDetail findDetail(@Param("id") String id);
|
||||
|
||||
List<VideoRestaurantLink> findVideoRestaurants(@Param("videoId") String videoId);
|
||||
|
||||
void updateStatus(@Param("id") String id, @Param("status") String status);
|
||||
|
||||
void updateTitle(@Param("id") String id, @Param("title") String title);
|
||||
|
||||
void updateTranscript(@Param("id") String id, @Param("transcript") String transcript);
|
||||
|
||||
void deleteVectorsByVideoOnly(@Param("videoId") String videoId);
|
||||
|
||||
void deleteReviewsByVideoOnly(@Param("videoId") String videoId);
|
||||
|
||||
void deleteFavoritesByVideoOnly(@Param("videoId") String videoId);
|
||||
|
||||
void deleteRestaurantsByVideoOnly(@Param("videoId") String videoId);
|
||||
|
||||
void deleteVideoRestaurants(@Param("videoId") String videoId);
|
||||
|
||||
void deleteVideo(@Param("videoId") String videoId);
|
||||
|
||||
void deleteOneVideoRestaurant(@Param("videoId") String videoId, @Param("restaurantId") String restaurantId);
|
||||
|
||||
void cleanupOrphanVectors(@Param("restaurantId") String restaurantId);
|
||||
|
||||
void cleanupOrphanReviews(@Param("restaurantId") String restaurantId);
|
||||
|
||||
void cleanupOrphanFavorites(@Param("restaurantId") String restaurantId);
|
||||
|
||||
void cleanupOrphanRestaurant(@Param("restaurantId") String restaurantId);
|
||||
|
||||
void insertVideo(@Param("id") String id,
|
||||
@Param("channelId") String channelId,
|
||||
@Param("videoId") String videoId,
|
||||
@Param("title") String title,
|
||||
@Param("url") String url,
|
||||
@Param("publishedAt") String publishedAt);
|
||||
|
||||
List<String> getExistingVideoIds(@Param("channelId") String channelId);
|
||||
|
||||
String getLatestVideoDate(@Param("channelId") String channelId);
|
||||
|
||||
List<Map<String, Object>> findPendingVideos(@Param("limit") int limit);
|
||||
|
||||
void updateVideoFields(@Param("id") String id,
|
||||
@Param("status") String status,
|
||||
@Param("transcript") String transcript,
|
||||
@Param("llmResponse") String llmResponse);
|
||||
|
||||
List<Map<String, Object>> findVideosForBulkExtract();
|
||||
|
||||
List<Map<String, Object>> findVideosWithoutTranscript();
|
||||
|
||||
List<Map<String, Object>> findVideosByIds(@Param("ids") List<String> ids);
|
||||
|
||||
List<Map<String, Object>> findVideosForExtractByIds(@Param("ids") List<String> ids);
|
||||
|
||||
void updateVideoRestaurantFields(@Param("videoId") String videoId,
|
||||
@Param("restaurantId") String restaurantId,
|
||||
@Param("foodsJson") String foodsJson,
|
||||
@Param("evaluation") String evaluation,
|
||||
@Param("guestsJson") String guestsJson);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.tasteby.security;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* Utility to extract current user info from SecurityContext.
|
||||
*/
|
||||
public final class AuthUtil {
|
||||
|
||||
private AuthUtil() {}
|
||||
|
||||
public static Claims getCurrentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !(auth.getPrincipal() instanceof Claims)) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
return (Claims) auth.getPrincipal();
|
||||
}
|
||||
|
||||
public static Claims getCurrentUserOrNull() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !(auth.getPrincipal() instanceof Claims)) {
|
||||
return null;
|
||||
}
|
||||
return (Claims) auth.getPrincipal();
|
||||
}
|
||||
|
||||
public static Claims requireAdmin() {
|
||||
Claims user = getCurrentUser();
|
||||
if (!Boolean.TRUE.equals(user.get("is_admin", Boolean.class))) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "관리자 권한이 필요합니다");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
public static String getUserId() {
|
||||
return getCurrentUser().getSubject();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.tasteby.security;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtTokenProvider tokenProvider;
|
||||
|
||||
public JwtAuthenticationFilter(JwtTokenProvider tokenProvider) {
|
||||
this.tokenProvider = tokenProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String header = request.getHeader("Authorization");
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
String token = header.substring(7).trim();
|
||||
if (tokenProvider.isValid(token)) {
|
||||
Claims claims = tokenProvider.parseToken(token);
|
||||
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
|
||||
if (Boolean.TRUE.equals(claims.get("is_admin", Boolean.class))) {
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
|
||||
}
|
||||
|
||||
var auth = new UsernamePasswordAuthenticationToken(claims, null, authorities);
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
}
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.tasteby.security;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class JwtTokenProvider {
|
||||
|
||||
private final SecretKey key;
|
||||
private final int expirationDays;
|
||||
|
||||
public JwtTokenProvider(
|
||||
@Value("${app.jwt.secret}") String secret,
|
||||
@Value("${app.jwt.expiration-days}") int expirationDays) {
|
||||
// Pad secret to at least 32 bytes for HS256
|
||||
String padded = secret.length() < 32
|
||||
? secret + "0".repeat(32 - secret.length())
|
||||
: secret;
|
||||
this.key = Keys.hmacShaKeyFor(padded.getBytes(StandardCharsets.UTF_8));
|
||||
this.expirationDays = expirationDays;
|
||||
}
|
||||
|
||||
public String createToken(Map<String, Object> userInfo) {
|
||||
Instant now = Instant.now();
|
||||
Instant exp = now.plus(expirationDays, ChronoUnit.DAYS);
|
||||
|
||||
return Jwts.builder()
|
||||
.subject((String) userInfo.get("id"))
|
||||
.claim("email", userInfo.get("email"))
|
||||
.claim("nickname", userInfo.get("nickname"))
|
||||
.claim("is_admin", userInfo.get("is_admin"))
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(exp))
|
||||
.signWith(key)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims parseToken(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(key)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
|
||||
public boolean isValid(String token) {
|
||||
try {
|
||||
parseToken(token);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
|
||||
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
|
||||
import com.google.api.client.http.javanet.NetHttpTransport;
|
||||
import com.google.api.client.json.gson.GsonFactory;
|
||||
import com.tasteby.domain.UserInfo;
|
||||
import com.tasteby.security.JwtTokenProvider;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class AuthService {
|
||||
|
||||
private final UserService userService;
|
||||
private final JwtTokenProvider jwtProvider;
|
||||
private final GoogleIdTokenVerifier verifier;
|
||||
|
||||
public AuthService(UserService userService, JwtTokenProvider jwtProvider,
|
||||
@Value("${app.google.client-id}") String googleClientId) {
|
||||
this.userService = userService;
|
||||
this.jwtProvider = jwtProvider;
|
||||
this.verifier = new GoogleIdTokenVerifier.Builder(
|
||||
new NetHttpTransport(), GsonFactory.getDefaultInstance())
|
||||
.setAudience(Collections.singletonList(googleClientId))
|
||||
.build();
|
||||
}
|
||||
|
||||
public Map<String, Object> loginGoogle(String idTokenString) {
|
||||
try {
|
||||
GoogleIdToken idToken = verifier.verify(idTokenString);
|
||||
if (idToken == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid Google token");
|
||||
}
|
||||
GoogleIdToken.Payload payload = idToken.getPayload();
|
||||
|
||||
UserInfo user = userService.findOrCreate(
|
||||
"google",
|
||||
payload.getSubject(),
|
||||
payload.getEmail(),
|
||||
(String) payload.get("name"),
|
||||
(String) payload.get("picture"));
|
||||
|
||||
// Convert to Map for JWT
|
||||
Map<String, Object> userMap = Map.of(
|
||||
"id", user.getId(),
|
||||
"email", user.getEmail() != null ? user.getEmail() : "",
|
||||
"nickname", user.getNickname() != null ? user.getNickname() : "",
|
||||
"is_admin", user.isAdmin()
|
||||
);
|
||||
String accessToken = jwtProvider.createToken(userMap);
|
||||
return Map.of("access_token", accessToken, "user", user);
|
||||
} catch (ResponseStatusException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid Google token: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public UserInfo getCurrentUser(String userId) {
|
||||
UserInfo user = userService.findById(userId);
|
||||
if (user == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class CacheService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CacheService.class);
|
||||
private static final String PREFIX = "tasteby:";
|
||||
|
||||
private final StringRedisTemplate redis;
|
||||
private final ObjectMapper mapper;
|
||||
private final Duration ttl;
|
||||
private boolean disabled = false;
|
||||
|
||||
public CacheService(StringRedisTemplate redis, ObjectMapper mapper,
|
||||
@Value("${app.cache.ttl-seconds:600}") int ttlSeconds) {
|
||||
this.redis = redis;
|
||||
this.mapper = mapper;
|
||||
this.ttl = Duration.ofSeconds(ttlSeconds);
|
||||
try {
|
||||
redis.getConnectionFactory().getConnection().ping();
|
||||
log.info("Redis connected");
|
||||
} catch (Exception e) {
|
||||
log.warn("Redis unavailable ({}), caching disabled", e.getMessage());
|
||||
disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
public String makeKey(String... parts) {
|
||||
return PREFIX + String.join(":", parts);
|
||||
}
|
||||
|
||||
public <T> T get(String key, Class<T> type) {
|
||||
if (disabled) return null;
|
||||
try {
|
||||
String val = redis.opsForValue().get(key);
|
||||
if (val != null) {
|
||||
return mapper.readValue(val, type);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Cache get error: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public String getRaw(String key) {
|
||||
if (disabled) return null;
|
||||
try {
|
||||
return redis.opsForValue().get(key);
|
||||
} catch (Exception e) {
|
||||
log.debug("Cache get error: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void set(String key, Object value) {
|
||||
if (disabled) return;
|
||||
try {
|
||||
String json = mapper.writeValueAsString(value);
|
||||
redis.opsForValue().set(key, json, ttl);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.debug("Cache set error: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void flush() {
|
||||
if (disabled) return;
|
||||
try {
|
||||
Set<String> keys = redis.keys(PREFIX + "*");
|
||||
if (keys != null && !keys.isEmpty()) {
|
||||
redis.delete(keys);
|
||||
}
|
||||
log.info("Cache flushed");
|
||||
} catch (Exception e) {
|
||||
log.debug("Cache flush error: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.domain.Channel;
|
||||
import com.tasteby.mapper.ChannelMapper;
|
||||
import com.tasteby.util.IdGenerator;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class ChannelService {
|
||||
|
||||
private final ChannelMapper mapper;
|
||||
|
||||
public ChannelService(ChannelMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public List<Channel> findAllActive() {
|
||||
return mapper.findAllActive();
|
||||
}
|
||||
|
||||
public String create(String channelId, String channelName, String titleFilter) {
|
||||
String id = IdGenerator.newId();
|
||||
mapper.insert(id, channelId, channelName, titleFilter);
|
||||
return id;
|
||||
}
|
||||
|
||||
public boolean deactivate(String channelId) {
|
||||
// Try deactivate by channel_id first, then by DB id
|
||||
int rows = mapper.deactivateByChannelId(channelId);
|
||||
if (rows == 0) {
|
||||
rows = mapper.deactivateById(channelId);
|
||||
}
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
public Channel findByChannelId(String channelId) {
|
||||
return mapper.findByChannelId(channelId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.domain.DaemonConfig;
|
||||
import com.tasteby.mapper.DaemonConfigMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class DaemonConfigService {
|
||||
|
||||
private final DaemonConfigMapper mapper;
|
||||
|
||||
public DaemonConfigService(DaemonConfigMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public DaemonConfig getConfig() {
|
||||
return mapper.getConfig();
|
||||
}
|
||||
|
||||
public void updateConfig(Map<String, Object> body) {
|
||||
DaemonConfig current = mapper.getConfig();
|
||||
if (current == null) return;
|
||||
|
||||
if (body.containsKey("scan_enabled")) {
|
||||
current.setScanEnabled(Boolean.TRUE.equals(body.get("scan_enabled")));
|
||||
}
|
||||
if (body.containsKey("scan_interval_min")) {
|
||||
current.setScanIntervalMin(((Number) body.get("scan_interval_min")).intValue());
|
||||
}
|
||||
if (body.containsKey("process_enabled")) {
|
||||
current.setProcessEnabled(Boolean.TRUE.equals(body.get("process_enabled")));
|
||||
}
|
||||
if (body.containsKey("process_interval_min")) {
|
||||
current.setProcessIntervalMin(((Number) body.get("process_interval_min")).intValue());
|
||||
}
|
||||
if (body.containsKey("process_limit")) {
|
||||
current.setProcessLimit(((Number) body.get("process_limit")).intValue());
|
||||
}
|
||||
mapper.updateConfig(current);
|
||||
}
|
||||
|
||||
public void updateLastScan() {
|
||||
mapper.updateLastScan();
|
||||
}
|
||||
|
||||
public void updateLastProcess() {
|
||||
mapper.updateLastProcess();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.domain.DaemonConfig;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
||||
/**
|
||||
* Background daemon that periodically scans channels and processes pending videos.
|
||||
*/
|
||||
@Service
|
||||
public class DaemonScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DaemonScheduler.class);
|
||||
|
||||
private final DaemonConfigService daemonConfigService;
|
||||
private final YouTubeService youTubeService;
|
||||
private final PipelineService pipelineService;
|
||||
private final CacheService cacheService;
|
||||
|
||||
public DaemonScheduler(DaemonConfigService daemonConfigService,
|
||||
YouTubeService youTubeService,
|
||||
PipelineService pipelineService,
|
||||
CacheService cacheService) {
|
||||
this.daemonConfigService = daemonConfigService;
|
||||
this.youTubeService = youTubeService;
|
||||
this.pipelineService = pipelineService;
|
||||
this.cacheService = cacheService;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 30_000) // Check every 30 seconds
|
||||
public void run() {
|
||||
try {
|
||||
var config = getConfig();
|
||||
if (config == null) return;
|
||||
|
||||
if (config.isScanEnabled()) {
|
||||
Instant lastScan = config.getLastScanAt() != null ? config.getLastScanAt().toInstant() : null;
|
||||
if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.getScanIntervalMin(), ChronoUnit.MINUTES))) {
|
||||
log.info("Running scheduled channel scan...");
|
||||
int newVideos = youTubeService.scanAllChannels();
|
||||
daemonConfigService.updateLastScan();
|
||||
if (newVideos > 0) {
|
||||
cacheService.flush();
|
||||
log.info("Scan completed: {} new videos", newVideos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.isProcessEnabled()) {
|
||||
Instant lastProcess = config.getLastProcessAt() != null ? config.getLastProcessAt().toInstant() : null;
|
||||
if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.getProcessIntervalMin(), ChronoUnit.MINUTES))) {
|
||||
log.info("Running scheduled video processing (limit={})...", config.getProcessLimit());
|
||||
int restaurants = pipelineService.processPending(config.getProcessLimit());
|
||||
daemonConfigService.updateLastProcess();
|
||||
if (restaurants > 0) {
|
||||
cacheService.flush();
|
||||
log.info("Processing completed: {} restaurants extracted", restaurants);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Daemon scheduler error: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private DaemonConfig getConfig() {
|
||||
try {
|
||||
return daemonConfigService.getConfig();
|
||||
} catch (Exception e) {
|
||||
log.debug("Cannot read daemon config: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.util.CuisineTypes;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class ExtractorService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ExtractorService.class);
|
||||
|
||||
private final OciGenAiService genAi;
|
||||
|
||||
private static final String EXTRACT_PROMPT = """
|
||||
다음은 유튜브 먹방/맛집 영상의 자막입니다.
|
||||
이 영상에서 언급된 모든 식당 정보를 추출하세요.
|
||||
|
||||
규칙:
|
||||
- 식당이 없으면 빈 배열 [] 반환
|
||||
- 각 식당에 대해 아래 필드를 JSON 배열로 반환
|
||||
- 확실하지 않은 정보는 null
|
||||
- 추가 설명 없이 JSON만 반환
|
||||
- 무조건 한글로 만들어주세요
|
||||
|
||||
필드:
|
||||
- name: 식당 이름 (string, 필수)
|
||||
- address: 주소 또는 위치 힌트 (string | null)
|
||||
- region: 지역을 "나라|시/도|구/군/시" 파이프(|) 구분 형식으로 작성 (string | null)
|
||||
- 한국 예시: "한국|서울|강남구", "한국|부산|해운대구", "한국|제주", "한국|강원|강릉시"
|
||||
- 해외 예시: "일본|도쿄", "일본|오사카", "싱가포르", "미국|뉴욕", "태국|방콕"
|
||||
- 나라는 한글로, 해외 도시도 한글로 표기
|
||||
- cuisine_type: 아래 목록에서 가장 적합한 것을 선택 (string, 필수). 반드시 아래 목록 중 하나를 사용:
|
||||
%s
|
||||
- price_range: 가격대 (예: 1만원대, 2-3만원) (string | null)
|
||||
- foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성)
|
||||
- evaluation: 평가 내용 (string | null)
|
||||
- guests: 함께한 게스트 (string[])
|
||||
|
||||
영상 제목: {title}
|
||||
자막:
|
||||
{transcript}
|
||||
|
||||
JSON 배열:""".formatted(CuisineTypes.CUISINE_LIST_TEXT);
|
||||
|
||||
public ExtractorService(OciGenAiService genAi) {
|
||||
this.genAi = genAi;
|
||||
}
|
||||
|
||||
public String getPrompt() {
|
||||
return EXTRACT_PROMPT;
|
||||
}
|
||||
|
||||
public record ExtractionResult(List<Map<String, Object>> restaurants, String rawResponse) {}
|
||||
|
||||
/**
|
||||
* Extract restaurant info from a video transcript using LLM.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public ExtractionResult extractRestaurants(String title, String transcript, String customPrompt) {
|
||||
// Truncate very long transcripts
|
||||
if (transcript.length() > 8000) {
|
||||
transcript = transcript.substring(0, 7000) + "\n...(중략)...\n" + transcript.substring(transcript.length() - 1000);
|
||||
}
|
||||
|
||||
String template = customPrompt != null ? customPrompt : EXTRACT_PROMPT;
|
||||
String prompt = template.replace("{title}", title).replace("{transcript}", transcript);
|
||||
|
||||
try {
|
||||
String raw = genAi.chat(prompt, 8192);
|
||||
Object result = genAi.parseJson(raw);
|
||||
if (result instanceof List<?> list) {
|
||||
return new ExtractionResult((List<Map<String, Object>>) list, raw);
|
||||
}
|
||||
if (result instanceof Map<?, ?> map) {
|
||||
return new ExtractionResult(List.of((Map<String, Object>) map), raw);
|
||||
}
|
||||
return new ExtractionResult(Collections.emptyList(), raw);
|
||||
} catch (Exception e) {
|
||||
log.error("Restaurant extraction failed: {}", e.getMessage());
|
||||
return new ExtractionResult(Collections.emptyList(), "");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class GeocodingService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GeocodingService.class);
|
||||
|
||||
private final WebClient webClient;
|
||||
private final ObjectMapper mapper;
|
||||
private final String apiKey;
|
||||
|
||||
public GeocodingService(ObjectMapper mapper,
|
||||
@Value("${app.google.maps-api-key}") String apiKey) {
|
||||
this.webClient = WebClient.builder()
|
||||
.baseUrl("https://maps.googleapis.com/maps/api")
|
||||
.build();
|
||||
this.mapper = mapper;
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up restaurant coordinates via Google Maps.
|
||||
* Tries Places Text Search first, falls back to Geocoding API.
|
||||
*/
|
||||
public Map<String, Object> geocodeRestaurant(String name, String address) {
|
||||
String query = name;
|
||||
if (address != null && !address.isBlank()) {
|
||||
query += " " + address;
|
||||
}
|
||||
|
||||
// Try Places Text Search
|
||||
Map<String, Object> result = placesTextSearch(query);
|
||||
if (result != null) return result;
|
||||
|
||||
// Fallback: Geocoding
|
||||
return geocode(query);
|
||||
}
|
||||
|
||||
private Map<String, Object> placesTextSearch(String query) {
|
||||
try {
|
||||
String response = webClient.get()
|
||||
.uri(uriBuilder -> uriBuilder.path("/place/textsearch/json")
|
||||
.queryParam("query", query)
|
||||
.queryParam("key", apiKey)
|
||||
.queryParam("language", "ko")
|
||||
.queryParam("type", "restaurant")
|
||||
.build())
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofSeconds(10));
|
||||
|
||||
JsonNode data = mapper.readTree(response);
|
||||
if (!"OK".equals(data.path("status").asText()) || !data.path("results").has(0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
JsonNode place = data.path("results").get(0);
|
||||
JsonNode loc = place.path("geometry").path("location");
|
||||
|
||||
var result = new HashMap<String, Object>();
|
||||
result.put("latitude", loc.path("lat").asDouble());
|
||||
result.put("longitude", loc.path("lng").asDouble());
|
||||
result.put("formatted_address", place.path("formatted_address").asText(""));
|
||||
result.put("google_place_id", place.path("place_id").asText(""));
|
||||
|
||||
if (!place.path("business_status").isMissingNode()) {
|
||||
result.put("business_status", place.path("business_status").asText());
|
||||
}
|
||||
if (!place.path("rating").isMissingNode()) {
|
||||
result.put("rating", place.path("rating").asDouble());
|
||||
}
|
||||
if (!place.path("user_ratings_total").isMissingNode()) {
|
||||
result.put("rating_count", place.path("user_ratings_total").asInt());
|
||||
}
|
||||
|
||||
// Fetch phone/website from Place Details
|
||||
String placeId = place.path("place_id").asText(null);
|
||||
if (placeId != null) {
|
||||
var details = placeDetails(placeId);
|
||||
if (details != null) {
|
||||
result.putAll(details);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.warn("Places text search failed for '{}': {}", query, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> placeDetails(String placeId) {
|
||||
try {
|
||||
String response = webClient.get()
|
||||
.uri(uriBuilder -> uriBuilder.path("/place/details/json")
|
||||
.queryParam("place_id", placeId)
|
||||
.queryParam("key", apiKey)
|
||||
.queryParam("language", "ko")
|
||||
.queryParam("fields", "formatted_phone_number,website")
|
||||
.build())
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofSeconds(10));
|
||||
|
||||
JsonNode data = mapper.readTree(response);
|
||||
if (!"OK".equals(data.path("status").asText())) return null;
|
||||
|
||||
JsonNode res = data.path("result");
|
||||
var details = new HashMap<String, Object>();
|
||||
if (!res.path("formatted_phone_number").isMissingNode()) {
|
||||
details.put("phone", res.path("formatted_phone_number").asText());
|
||||
}
|
||||
if (!res.path("website").isMissingNode()) {
|
||||
details.put("website", res.path("website").asText());
|
||||
}
|
||||
return details;
|
||||
} catch (Exception e) {
|
||||
log.warn("Place details failed for '{}': {}", placeId, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Korean address into region format "나라|시/도|구/군".
|
||||
* Example: "대한민국 서울특별시 강남구 역삼동 123" → "한국|서울|강남구"
|
||||
*/
|
||||
public static String parseRegionFromAddress(String address) {
|
||||
if (address == null || address.isBlank()) return null;
|
||||
String[] parts = address.split("\\s+");
|
||||
String country = "";
|
||||
String city = "";
|
||||
String district = "";
|
||||
|
||||
for (String p : parts) {
|
||||
if (p.equals("대한민국") || p.equals("South Korea")) {
|
||||
country = "한국";
|
||||
} else if (p.endsWith("특별시") || p.endsWith("광역시") || p.endsWith("특별자치시")) {
|
||||
city = p.replace("특별시", "").replace("광역시", "").replace("특별자치시", "");
|
||||
} else if (p.endsWith("도") && !p.endsWith("동") && p.length() <= 5) {
|
||||
city = p;
|
||||
} else if (p.endsWith("구") || p.endsWith("군") || (p.endsWith("시") && !city.isEmpty())) {
|
||||
if (district.isEmpty()) district = p;
|
||||
}
|
||||
}
|
||||
|
||||
if (country.isEmpty() && !city.isEmpty()) country = "한국";
|
||||
if (country.isEmpty()) return null;
|
||||
return country + "|" + city + "|" + district;
|
||||
}
|
||||
|
||||
private Map<String, Object> geocode(String query) {
|
||||
try {
|
||||
String response = webClient.get()
|
||||
.uri(uriBuilder -> uriBuilder.path("/geocode/json")
|
||||
.queryParam("address", query)
|
||||
.queryParam("key", apiKey)
|
||||
.queryParam("language", "ko")
|
||||
.build())
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofSeconds(10));
|
||||
|
||||
JsonNode data = mapper.readTree(response);
|
||||
if (!"OK".equals(data.path("status").asText()) || !data.path("results").has(0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
JsonNode result = data.path("results").get(0);
|
||||
JsonNode loc = result.path("geometry").path("location");
|
||||
|
||||
var map = new HashMap<String, Object>();
|
||||
map.put("latitude", loc.path("lat").asDouble());
|
||||
map.put("longitude", loc.path("lng").asDouble());
|
||||
map.put("formatted_address", result.path("formatted_address").asText(""));
|
||||
map.put("google_place_id", "");
|
||||
return map;
|
||||
} catch (Exception e) {
|
||||
log.warn("Geocoding failed for '{}': {}", query, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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 jakarta.annotation.PreDestroy;
|
||||
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;
|
||||
private GenerativeAiInferenceClient chatClient;
|
||||
private GenerativeAiInferenceClient embedClient;
|
||||
|
||||
public OciGenAiService(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
try {
|
||||
ConfigFileReader.ConfigFile configFile = ConfigFileReader.parseDefault();
|
||||
authProvider = new ConfigFileAuthenticationDetailsProvider(configFile);
|
||||
chatClient = GenerativeAiInferenceClient.builder()
|
||||
.endpoint(chatEndpoint).build(authProvider);
|
||||
embedClient = GenerativeAiInferenceClient.builder()
|
||||
.endpoint(embedEndpoint).build(authProvider);
|
||||
log.info("OCI GenAI auth configured (clients initialized)");
|
||||
} catch (Exception e) {
|
||||
log.warn("OCI config not found, GenAI features disabled: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void destroy() {
|
||||
if (chatClient != null) chatClient.close();
|
||||
if (embedClient != null) embedClient.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Call OCI GenAI LLM (Chat).
|
||||
*/
|
||||
public String chat(String prompt, int maxTokens) {
|
||||
if (chatClient == null) throw new IllegalStateException("OCI GenAI not configured");
|
||||
|
||||
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 = chatClient.chat(
|
||||
ChatRequest.builder().chatDetails(chatDetails).build());
|
||||
|
||||
var chatResult = (GenericChatResponse) response.getChatResult().getChatResponse();
|
||||
var choice = chatResult.getChoices().get(0);
|
||||
var content = ((TextContent) choice.getMessage().getContent().get(0)).getText();
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embeddings for a list of texts.
|
||||
*/
|
||||
public List<List<Double>> embedTexts(List<String> texts) {
|
||||
if (authProvider == null) throw new IllegalStateException("OCI GenAI not configured");
|
||||
|
||||
List<List<Double>> allEmbeddings = new ArrayList<>();
|
||||
for (int i = 0; i < texts.size(); i += EMBED_BATCH_SIZE) {
|
||||
List<String> batch = texts.subList(i, Math.min(i + EMBED_BATCH_SIZE, texts.size()));
|
||||
allEmbeddings.addAll(embedBatch(batch));
|
||||
}
|
||||
return allEmbeddings;
|
||||
}
|
||||
|
||||
private List<List<Double>> embedBatch(List<String> texts) {
|
||||
if (embedClient == null) throw new IllegalStateException("OCI GenAI not configured");
|
||||
|
||||
var embedDetails = EmbedTextDetails.builder()
|
||||
.inputs(texts)
|
||||
.servingMode(OnDemandServingMode.builder().modelId(embedModelId).build())
|
||||
.compartmentId(compartmentId)
|
||||
.inputType(EmbedTextDetails.InputType.SearchDocument)
|
||||
.build();
|
||||
|
||||
EmbedTextResponse response = embedClient.embedText(
|
||||
EmbedTextRequest.builder().embedTextDetails(embedDetails).build());
|
||||
|
||||
return response.getEmbedTextResult().getEmbeddings()
|
||||
.stream()
|
||||
.map(emb -> emb.stream().map(Number::doubleValue).toList())
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse LLM response as JSON (handles markdown code blocks, truncated arrays, etc.)
|
||||
*/
|
||||
public Object parseJson(String raw) {
|
||||
// Strip markdown code blocks
|
||||
raw = raw.replaceAll("(?m)^```(?:json)?\\s*|\\s*```$", "").trim();
|
||||
// Remove trailing commas
|
||||
raw = raw.replaceAll(",\\s*([}\\]])", "$1");
|
||||
|
||||
try {
|
||||
return mapper.readValue(raw, Object.class);
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
// Try to recover truncated array
|
||||
if (raw.trim().startsWith("[")) {
|
||||
List<Object> items = new ArrayList<>();
|
||||
int idx = raw.indexOf('[') + 1;
|
||||
while (idx < raw.length()) {
|
||||
while (idx < raw.length() && " \t\n\r,".indexOf(raw.charAt(idx)) >= 0) idx++;
|
||||
if (idx >= raw.length() || raw.charAt(idx) == ']') break;
|
||||
|
||||
// Try to parse next object
|
||||
boolean found = false;
|
||||
for (int end = idx + 1; end <= raw.length(); end++) {
|
||||
try {
|
||||
Object obj = mapper.readValue(raw.substring(idx, end), Object.class);
|
||||
items.add(obj);
|
||||
idx = end;
|
||||
found = true;
|
||||
break;
|
||||
} catch (Exception ignored2) {}
|
||||
}
|
||||
if (!found) break;
|
||||
}
|
||||
if (!items.isEmpty()) {
|
||||
log.info("Recovered {} items from truncated JSON", items.size());
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException("JSON parse failed: " + raw.substring(0, Math.min(80, raw.length())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Data pipeline: process pending videos end-to-end.
|
||||
* 1. Fetch transcript
|
||||
* 2. Extract restaurant info via LLM
|
||||
* 3. Geocode each restaurant
|
||||
* 4. Save to DB + generate vector embeddings
|
||||
*/
|
||||
@Service
|
||||
public class PipelineService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PipelineService.class);
|
||||
|
||||
private final YouTubeService youTubeService;
|
||||
private final ExtractorService extractorService;
|
||||
private final GeocodingService geocodingService;
|
||||
private final RestaurantService restaurantService;
|
||||
private final VideoService videoService;
|
||||
private final VectorService vectorService;
|
||||
private final CacheService cacheService;
|
||||
|
||||
public PipelineService(YouTubeService youTubeService,
|
||||
ExtractorService extractorService,
|
||||
GeocodingService geocodingService,
|
||||
RestaurantService restaurantService,
|
||||
VideoService videoService,
|
||||
VectorService vectorService,
|
||||
CacheService cacheService) {
|
||||
this.youTubeService = youTubeService;
|
||||
this.extractorService = extractorService;
|
||||
this.geocodingService = geocodingService;
|
||||
this.restaurantService = restaurantService;
|
||||
this.videoService = videoService;
|
||||
this.vectorService = vectorService;
|
||||
this.cacheService = cacheService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single pending video. Returns number of restaurants found.
|
||||
*/
|
||||
public int processVideo(Map<String, Object> video) {
|
||||
String videoDbId = (String) video.get("id");
|
||||
String videoId = (String) video.get("video_id");
|
||||
String title = (String) video.get("title");
|
||||
|
||||
log.info("Processing video: {} ({})", title, videoId);
|
||||
updateVideoStatus(videoDbId, "processing", null, null);
|
||||
|
||||
try {
|
||||
// 1. Transcript
|
||||
var transcript = youTubeService.getTranscript(videoId, "auto");
|
||||
if (transcript == null || transcript.text() == null) {
|
||||
log.warn("No transcript for {}, marking done", videoId);
|
||||
updateVideoStatus(videoDbId, "done", null, null);
|
||||
return 0;
|
||||
}
|
||||
updateVideoStatus(videoDbId, "processing", transcript.text(), null);
|
||||
|
||||
// 2. LLM extraction + geocode + save
|
||||
return processExtract(video, transcript.text(), null);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Pipeline error for {}: {}", videoId, e.getMessage(), e);
|
||||
updateVideoStatus(videoDbId, "error", null, null);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run LLM extraction + geocode + save on existing transcript.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public int processExtract(Map<String, Object> video, String transcript, String customPrompt) {
|
||||
String videoDbId = (String) video.get("id");
|
||||
String title = (String) video.get("title");
|
||||
|
||||
var result = extractorService.extractRestaurants(title, transcript, customPrompt);
|
||||
if (result.restaurants().isEmpty()) {
|
||||
updateVideoStatus(videoDbId, "done", null, result.rawResponse());
|
||||
return 0;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
for (var restData : result.restaurants()) {
|
||||
String name = (String) restData.get("name");
|
||||
if (name == null) continue;
|
||||
|
||||
// Geocode
|
||||
var geo = geocodingService.geocodeRestaurant(
|
||||
name, (String) restData.get("address"));
|
||||
|
||||
// Build upsert data
|
||||
var data = new HashMap<String, Object>();
|
||||
data.put("name", name);
|
||||
data.put("address", geo != null ? geo.get("formatted_address") : restData.get("address"));
|
||||
data.put("region", restData.get("region"));
|
||||
data.put("latitude", geo != null ? geo.get("latitude") : null);
|
||||
data.put("longitude", geo != null ? geo.get("longitude") : null);
|
||||
data.put("cuisine_type", restData.get("cuisine_type"));
|
||||
data.put("price_range", restData.get("price_range"));
|
||||
data.put("google_place_id", geo != null ? geo.get("google_place_id") : null);
|
||||
data.put("phone", geo != null ? geo.get("phone") : null);
|
||||
data.put("website", geo != null ? geo.get("website") : null);
|
||||
data.put("business_status", geo != null ? geo.get("business_status") : null);
|
||||
data.put("rating", geo != null ? geo.get("rating") : null);
|
||||
data.put("rating_count", geo != null ? geo.get("rating_count") : null);
|
||||
|
||||
String restId = restaurantService.upsert(data);
|
||||
|
||||
// Link video <-> restaurant
|
||||
var foods = restData.get("foods_mentioned");
|
||||
var evaluationRaw = restData.get("evaluation");
|
||||
var guests = restData.get("guests");
|
||||
|
||||
// evaluation must be stored as valid JSON (DB has IS JSON check constraint)
|
||||
// Store as JSON string literal: "평가 내용" (valid JSON)
|
||||
String evaluationJson = null;
|
||||
if (evaluationRaw instanceof Map<?, ?>) {
|
||||
evaluationJson = JsonUtil.toJson(evaluationRaw);
|
||||
} else if (evaluationRaw instanceof String s && !s.isBlank()) {
|
||||
evaluationJson = JsonUtil.toJson(s);
|
||||
}
|
||||
|
||||
restaurantService.linkVideoRestaurant(
|
||||
videoDbId, restId,
|
||||
foods instanceof List<?> ? (List<String>) foods : null,
|
||||
evaluationJson,
|
||||
guests instanceof List<?> ? (List<String>) guests : null
|
||||
);
|
||||
|
||||
// Vector embeddings
|
||||
var chunks = VectorService.buildChunks(name, restData, title);
|
||||
if (!chunks.isEmpty()) {
|
||||
try {
|
||||
vectorService.saveRestaurantVectors(restId, chunks);
|
||||
} catch (Exception e) {
|
||||
log.warn("Vector save failed for {}: {}", name, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
count++;
|
||||
log.info("Saved restaurant: {} (geocoded={})", name, geo != null);
|
||||
}
|
||||
|
||||
updateVideoStatus(videoDbId, "done", null, result.rawResponse());
|
||||
log.info("Video {} done: {} restaurants", video.get("video_id"), count);
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process up to `limit` pending videos.
|
||||
*/
|
||||
public int processPending(int limit) {
|
||||
var videos = videoService.findPendingVideos(limit);
|
||||
if (videos.isEmpty()) {
|
||||
log.info("No pending videos");
|
||||
return 0;
|
||||
}
|
||||
int total = 0;
|
||||
for (var v : videos) {
|
||||
total += processVideo(v);
|
||||
}
|
||||
if (total > 0) cacheService.flush();
|
||||
return total;
|
||||
}
|
||||
|
||||
private void updateVideoStatus(String videoDbId, String status, String transcript, String llmRaw) {
|
||||
videoService.updateVideoFields(videoDbId, status, transcript, llmRaw);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.mapper.RestaurantMapper;
|
||||
import com.tasteby.util.IdGenerator;
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class RestaurantService {
|
||||
|
||||
private final RestaurantMapper mapper;
|
||||
|
||||
public RestaurantService(RestaurantMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel) {
|
||||
List<Restaurant> restaurants = mapper.findAll(limit, offset, cuisine, region, channel);
|
||||
enrichRestaurants(restaurants);
|
||||
return restaurants;
|
||||
}
|
||||
|
||||
public List<Restaurant> findWithoutTabling() {
|
||||
return mapper.findWithoutTabling();
|
||||
}
|
||||
|
||||
public List<Restaurant> findWithoutCatchtable() {
|
||||
return mapper.findWithoutCatchtable();
|
||||
}
|
||||
|
||||
public Restaurant findById(String id) {
|
||||
Restaurant restaurant = mapper.findById(id);
|
||||
if (restaurant == null) return null;
|
||||
enrichRestaurants(List.of(restaurant));
|
||||
return restaurant;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findVideoLinks(String restaurantId) {
|
||||
var rows = mapper.findVideoLinks(restaurantId);
|
||||
return rows.stream().map(row -> {
|
||||
var m = JsonUtil.lowerKeys(row);
|
||||
m.put("foods_mentioned", JsonUtil.parseStringList(m.get("foods_mentioned")));
|
||||
m.put("evaluation", JsonUtil.parseMap(m.get("evaluation")));
|
||||
m.put("guests", JsonUtil.parseStringList(m.get("guests")));
|
||||
return m;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
public void update(String id, Map<String, Object> fields) {
|
||||
mapper.updateFields(id, fields);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(String id) {
|
||||
mapper.deleteVectors(id);
|
||||
mapper.deleteReviews(id);
|
||||
mapper.deleteFavorites(id);
|
||||
mapper.deleteVideoRestaurants(id);
|
||||
mapper.deleteRestaurant(id);
|
||||
}
|
||||
|
||||
public String upsert(Map<String, Object> data) {
|
||||
String placeId = (String) data.get("google_place_id");
|
||||
String existingId = null;
|
||||
if (placeId != null && !placeId.isBlank()) {
|
||||
existingId = mapper.findIdByPlaceId(placeId);
|
||||
}
|
||||
if (existingId == null) {
|
||||
existingId = mapper.findIdByName((String) data.get("name"));
|
||||
}
|
||||
|
||||
Restaurant r = Restaurant.builder()
|
||||
.name(truncateBytes((String) data.get("name"), 200))
|
||||
.address(truncateBytes((String) data.get("address"), 500))
|
||||
.region((String) data.get("region"))
|
||||
.latitude(data.get("latitude") instanceof Number n ? n.doubleValue() : null)
|
||||
.longitude(data.get("longitude") instanceof Number n ? n.doubleValue() : null)
|
||||
.cuisineType((String) data.get("cuisine_type"))
|
||||
.priceRange((String) data.get("price_range"))
|
||||
.googlePlaceId(placeId)
|
||||
.phone((String) data.get("phone"))
|
||||
.website((String) data.get("website"))
|
||||
.businessStatus((String) data.get("business_status"))
|
||||
.rating(data.get("rating") instanceof Number n ? n.doubleValue() : null)
|
||||
.ratingCount(data.get("rating_count") instanceof Number n ? n.intValue() : null)
|
||||
.build();
|
||||
|
||||
if (existingId != null) {
|
||||
r.setId(existingId);
|
||||
mapper.updateRestaurant(r);
|
||||
return existingId;
|
||||
} else {
|
||||
String newId = IdGenerator.newId();
|
||||
r.setId(newId);
|
||||
mapper.insertRestaurant(r);
|
||||
return newId;
|
||||
}
|
||||
}
|
||||
|
||||
public void linkVideoRestaurant(String videoId, String restaurantId, List<String> foods, String evaluation, List<String> guests) {
|
||||
String id = IdGenerator.newId();
|
||||
String foodsJson = foods != null ? JsonUtil.toJson(foods) : null;
|
||||
String guestsJson = guests != null ? JsonUtil.toJson(guests) : null;
|
||||
mapper.linkVideoRestaurant(id, videoId, restaurantId, foodsJson, evaluation, guestsJson);
|
||||
}
|
||||
|
||||
public void updateCuisineType(String id, String cuisineType) {
|
||||
mapper.updateCuisineType(id, cuisineType);
|
||||
}
|
||||
|
||||
public void updateFoodsMentioned(String id, String foods) {
|
||||
mapper.updateFoodsMentioned(id, foods);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findForRemapCuisine() {
|
||||
return mapper.findForRemapCuisine();
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findForRemapFoods() {
|
||||
return mapper.findForRemapFoods();
|
||||
}
|
||||
|
||||
private void enrichRestaurants(List<Restaurant> restaurants) {
|
||||
if (restaurants.isEmpty()) return;
|
||||
List<String> ids = restaurants.stream().map(Restaurant::getId).filter(Objects::nonNull).toList();
|
||||
if (ids.isEmpty()) return;
|
||||
|
||||
// Channels
|
||||
List<Map<String, Object>> channelRows = mapper.findChannelsByRestaurantIds(ids);
|
||||
Map<String, List<String>> channelMap = new HashMap<>();
|
||||
for (var row : channelRows) {
|
||||
String rid = (String) row.getOrDefault("restaurant_id", row.get("RESTAURANT_ID"));
|
||||
String ch = (String) row.getOrDefault("channel_name", row.get("CHANNEL_NAME"));
|
||||
if (rid != null && ch != null) {
|
||||
channelMap.computeIfAbsent(rid, k -> new ArrayList<>()).add(ch);
|
||||
}
|
||||
}
|
||||
|
||||
// Foods
|
||||
List<Map<String, Object>> foodRows = mapper.findFoodsByRestaurantIds(ids);
|
||||
Map<String, Set<String>> foodMap = new HashMap<>();
|
||||
for (var row : foodRows) {
|
||||
String rid = (String) row.getOrDefault("restaurant_id", row.get("RESTAURANT_ID"));
|
||||
Object foods = row.getOrDefault("foods_mentioned", row.get("FOODS_MENTIONED"));
|
||||
if (rid != null && foods != null) {
|
||||
List<String> parsed = JsonUtil.parseStringList(foods);
|
||||
foodMap.computeIfAbsent(rid, k -> new LinkedHashSet<>()).addAll(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
for (var r : restaurants) {
|
||||
r.setChannels(channelMap.getOrDefault(r.getId(), List.of()));
|
||||
Set<String> foods = foodMap.get(r.getId());
|
||||
r.setFoodsMentioned(foods != null ? new ArrayList<>(foods) : List.of());
|
||||
}
|
||||
}
|
||||
|
||||
private String truncateBytes(String s, int maxBytes) {
|
||||
if (s == null) return null;
|
||||
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length <= maxBytes) return s;
|
||||
return new String(bytes, 0, maxBytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.domain.Review;
|
||||
import com.tasteby.mapper.ReviewMapper;
|
||||
import com.tasteby.util.IdGenerator;
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class ReviewService {
|
||||
|
||||
private final ReviewMapper mapper;
|
||||
|
||||
public ReviewService(ReviewMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public List<Review> findByRestaurant(String restaurantId, int limit, int offset) {
|
||||
return mapper.findByRestaurant(restaurantId, limit, offset);
|
||||
}
|
||||
|
||||
public Map<String, Object> getAvgRating(String restaurantId) {
|
||||
Map<String, Object> result = mapper.getAvgRating(restaurantId);
|
||||
return result != null ? JsonUtil.lowerKeys(result) : Map.of("avg_rating", 0.0, "review_count", 0);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Review create(String userId, String restaurantId, double rating, String reviewText, LocalDate visitedAt) {
|
||||
String id = IdGenerator.newId();
|
||||
String visitedStr = visitedAt != null ? visitedAt.toString() : null;
|
||||
mapper.insertReview(id, userId, restaurantId, rating, reviewText, visitedStr);
|
||||
return mapper.findById(id);
|
||||
}
|
||||
|
||||
public boolean update(String reviewId, String userId, Double rating, String reviewText, LocalDate visitedAt) {
|
||||
String visitedStr = visitedAt != null ? visitedAt.toString() : null;
|
||||
return mapper.updateReview(reviewId, userId, rating, reviewText, visitedStr) > 0;
|
||||
}
|
||||
|
||||
public boolean delete(String reviewId, String userId) {
|
||||
return mapper.deleteReview(reviewId, userId) > 0;
|
||||
}
|
||||
|
||||
public List<Review> findByUser(String userId, int limit, int offset) {
|
||||
return mapper.findByUser(userId, limit, offset);
|
||||
}
|
||||
|
||||
public boolean isFavorited(String userId, String restaurantId) {
|
||||
return mapper.countFavorite(userId, restaurantId) > 0;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean toggleFavorite(String userId, String restaurantId) {
|
||||
String existingId = mapper.findFavoriteId(userId, restaurantId);
|
||||
if (existingId != null) {
|
||||
mapper.deleteFavorite(userId, restaurantId);
|
||||
return false;
|
||||
} else {
|
||||
mapper.insertFavorite(IdGenerator.newId(), userId, restaurantId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public List<Restaurant> getUserFavorites(String userId) {
|
||||
return mapper.getUserFavorites(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.mapper.SearchMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class SearchService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SearchService.class);
|
||||
|
||||
private final SearchMapper searchMapper;
|
||||
private final RestaurantService restaurantService;
|
||||
private final VectorService vectorService;
|
||||
private final CacheService cache;
|
||||
|
||||
public SearchService(SearchMapper searchMapper,
|
||||
RestaurantService restaurantService,
|
||||
VectorService vectorService,
|
||||
CacheService cache) {
|
||||
this.searchMapper = searchMapper;
|
||||
this.restaurantService = restaurantService;
|
||||
this.vectorService = vectorService;
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
public List<Restaurant> search(String q, String mode, int limit) {
|
||||
String key = cache.makeKey("search", "q=" + q, "m=" + mode, "l=" + limit);
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
return mapper.readValue(cached, new com.fasterxml.jackson.core.type.TypeReference<List<Restaurant>>() {});
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
List<Restaurant> result;
|
||||
switch (mode) {
|
||||
case "semantic" -> result = semanticSearch(q, limit);
|
||||
case "hybrid" -> {
|
||||
var kw = keywordSearch(q, limit);
|
||||
var sem = semanticSearch(q, limit);
|
||||
Set<String> seen = new HashSet<>();
|
||||
var merged = new ArrayList<Restaurant>();
|
||||
for (var r : kw) { if (seen.add(r.getId())) merged.add(r); }
|
||||
for (var r : sem) { if (seen.add(r.getId())) merged.add(r); }
|
||||
result = merged.size() > limit ? merged.subList(0, limit) : merged;
|
||||
}
|
||||
default -> result = keywordSearch(q, limit);
|
||||
}
|
||||
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Restaurant> keywordSearch(String q, int limit) {
|
||||
String pattern = "%" + q + "%";
|
||||
List<Restaurant> results = searchMapper.keywordSearch(pattern, limit);
|
||||
if (!results.isEmpty()) {
|
||||
attachChannels(results);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private List<Restaurant> semanticSearch(String q, int limit) {
|
||||
try {
|
||||
var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), 0.57);
|
||||
if (similar.isEmpty()) return List.of();
|
||||
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
for (var s : similar) {
|
||||
seen.add((String) s.get("restaurant_id"));
|
||||
}
|
||||
|
||||
List<Restaurant> results = new ArrayList<>();
|
||||
for (String rid : seen) {
|
||||
if (results.size() >= limit) break;
|
||||
var r = restaurantService.findById(rid);
|
||||
if (r != null && r.getLatitude() != null) {
|
||||
results.add(r);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
} catch (Exception e) {
|
||||
log.warn("Semantic search failed, falling back to keyword: {}", e.getMessage());
|
||||
return keywordSearch(q, limit);
|
||||
}
|
||||
}
|
||||
|
||||
private void attachChannels(List<Restaurant> restaurants) {
|
||||
List<String> ids = restaurants.stream().map(Restaurant::getId).filter(Objects::nonNull).toList();
|
||||
if (ids.isEmpty()) return;
|
||||
|
||||
var channelRows = searchMapper.findChannelsByRestaurantIds(ids);
|
||||
Map<String, List<String>> chMap = new HashMap<>();
|
||||
for (var row : channelRows) {
|
||||
String rid = (String) row.getOrDefault("restaurant_id", row.get("RESTAURANT_ID"));
|
||||
String ch = (String) row.getOrDefault("channel_name", row.get("CHANNEL_NAME"));
|
||||
if (rid != null && ch != null) {
|
||||
chMap.computeIfAbsent(rid, k -> new ArrayList<>()).add(ch);
|
||||
}
|
||||
}
|
||||
for (var r : restaurants) {
|
||||
r.setChannels(chMap.getOrDefault(r.getId(), List.of()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.domain.SiteVisitStats;
|
||||
import com.tasteby.mapper.StatsMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class StatsService {
|
||||
|
||||
private final StatsMapper mapper;
|
||||
|
||||
public StatsService(StatsMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public void recordVisit() {
|
||||
mapper.recordVisit();
|
||||
}
|
||||
|
||||
public SiteVisitStats getVisits() {
|
||||
return SiteVisitStats.builder()
|
||||
.today(mapper.getTodayVisits())
|
||||
.total(mapper.getTotalVisits())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.domain.UserInfo;
|
||||
import com.tasteby.mapper.UserMapper;
|
||||
import com.tasteby.util.IdGenerator;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
private final UserMapper mapper;
|
||||
|
||||
public UserService(UserMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserInfo findOrCreate(String provider, String providerId, String email, String nickname, String avatarUrl) {
|
||||
UserInfo existing = mapper.findByProviderAndProviderId(provider, providerId);
|
||||
if (existing != null) {
|
||||
mapper.updateLastLogin(existing.getId());
|
||||
return mapper.findById(existing.getId());
|
||||
}
|
||||
UserInfo user = UserInfo.builder()
|
||||
.id(IdGenerator.newId())
|
||||
.provider(provider)
|
||||
.providerId(providerId)
|
||||
.email(email)
|
||||
.nickname(nickname)
|
||||
.avatarUrl(avatarUrl)
|
||||
.build();
|
||||
mapper.insert(user);
|
||||
return mapper.findById(user.getId());
|
||||
}
|
||||
|
||||
public UserInfo findById(String userId) {
|
||||
return mapper.findById(userId);
|
||||
}
|
||||
|
||||
public List<UserInfo> findAllWithCounts(int limit, int offset) {
|
||||
return mapper.findAllWithCounts(limit, offset);
|
||||
}
|
||||
|
||||
public int countAll() {
|
||||
return mapper.countAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class VectorService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(VectorService.class);
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
private final OciGenAiService genAi;
|
||||
|
||||
public VectorService(NamedParameterJdbcTemplate jdbc, OciGenAiService genAi) {
|
||||
this.jdbc = jdbc;
|
||||
this.genAi = genAi;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search similar restaurants by vector distance.
|
||||
*/
|
||||
public List<Map<String, Object>> searchSimilar(String query, int topK, double maxDistance) {
|
||||
List<List<Double>> embeddings = genAi.embedTexts(List.of(query));
|
||||
if (embeddings.isEmpty()) return List.of();
|
||||
|
||||
// Convert to float array for Oracle VECTOR type
|
||||
float[] queryVec = new float[embeddings.getFirst().size()];
|
||||
for (int i = 0; i < queryVec.length; i++) {
|
||||
queryVec[i] = embeddings.getFirst().get(i).floatValue();
|
||||
}
|
||||
|
||||
String sql = """
|
||||
SELECT rv.restaurant_id, rv.chunk_text,
|
||||
VECTOR_DISTANCE(rv.embedding, :qvec, COSINE) AS dist
|
||||
FROM restaurant_vectors rv
|
||||
WHERE VECTOR_DISTANCE(rv.embedding, :qvec2, COSINE) <= :max_dist
|
||||
ORDER BY dist
|
||||
FETCH FIRST :k ROWS ONLY
|
||||
""";
|
||||
|
||||
var params = new MapSqlParameterSource();
|
||||
params.addValue("qvec", queryVec);
|
||||
params.addValue("qvec2", queryVec);
|
||||
params.addValue("max_dist", maxDistance);
|
||||
params.addValue("k", topK);
|
||||
|
||||
return jdbc.query(sql, params, (rs, rowNum) -> {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("restaurant_id", rs.getString("RESTAURANT_ID"));
|
||||
m.put("chunk_text", JsonUtil.readClob(rs.getObject("CHUNK_TEXT")));
|
||||
m.put("distance", rs.getDouble("DIST"));
|
||||
return m;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save vector embeddings for a restaurant.
|
||||
*/
|
||||
public void saveRestaurantVectors(String restaurantId, List<String> chunks) {
|
||||
if (chunks.isEmpty()) return;
|
||||
|
||||
List<List<Double>> embeddings = genAi.embedTexts(chunks);
|
||||
|
||||
String sql = """
|
||||
INSERT INTO restaurant_vectors (id, restaurant_id, chunk_text, embedding)
|
||||
VALUES (:id, :rid, :chunk, :emb)
|
||||
""";
|
||||
|
||||
for (int i = 0; i < chunks.size(); i++) {
|
||||
String id = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase();
|
||||
float[] vec = new float[embeddings.get(i).size()];
|
||||
for (int j = 0; j < vec.length; j++) {
|
||||
vec[j] = embeddings.get(i).get(j).floatValue();
|
||||
}
|
||||
var params = new MapSqlParameterSource();
|
||||
params.addValue("id", id);
|
||||
params.addValue("rid", restaurantId);
|
||||
params.addValue("chunk", chunks.get(i));
|
||||
params.addValue("emb", vec);
|
||||
jdbc.update(sql, params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a text chunk for vector embedding from restaurant data.
|
||||
*/
|
||||
public static List<String> buildChunks(String name, Map<String, Object> data, String videoTitle) {
|
||||
var parts = new ArrayList<String>();
|
||||
parts.add("식당: " + name);
|
||||
if (data.get("region") != null) parts.add("지역: " + data.get("region"));
|
||||
if (data.get("cuisine_type") != null) parts.add("음식 종류: " + data.get("cuisine_type"));
|
||||
if (data.get("foods_mentioned") instanceof List<?> foods && !foods.isEmpty()) {
|
||||
parts.add("메뉴: " + String.join(", ", foods.stream().map(Object::toString).toList()));
|
||||
}
|
||||
if (data.get("evaluation") != null) parts.add("평가: " + data.get("evaluation"));
|
||||
if (data.get("price_range") != null) parts.add("가격대: " + data.get("price_range"));
|
||||
parts.add("영상: " + videoTitle);
|
||||
return List.of(String.join("\n", parts));
|
||||
}
|
||||
}
|
||||
134
backend-java/src/main/java/com/tasteby/service/VideoService.java
Normal file
134
backend-java/src/main/java/com/tasteby/service/VideoService.java
Normal file
@@ -0,0 +1,134 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.domain.VideoDetail;
|
||||
import com.tasteby.domain.VideoRestaurantLink;
|
||||
import com.tasteby.domain.VideoSummary;
|
||||
import com.tasteby.mapper.VideoMapper;
|
||||
import com.tasteby.util.IdGenerator;
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class VideoService {
|
||||
|
||||
private final VideoMapper mapper;
|
||||
|
||||
public VideoService(VideoMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public List<VideoSummary> findAll(String status) {
|
||||
return mapper.findAll(status);
|
||||
}
|
||||
|
||||
public VideoDetail findDetail(String id) {
|
||||
VideoDetail detail = mapper.findDetail(id);
|
||||
if (detail == null) return null;
|
||||
List<VideoRestaurantLink> restaurants = mapper.findVideoRestaurants(id);
|
||||
detail.setRestaurants(restaurants != null ? restaurants : List.of());
|
||||
return detail;
|
||||
}
|
||||
|
||||
public void updateTitle(String id, String title) {
|
||||
mapper.updateTitle(id, title);
|
||||
}
|
||||
|
||||
public void updateStatus(String id, String status) {
|
||||
mapper.updateStatus(id, status);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(String id) {
|
||||
mapper.deleteVectorsByVideoOnly(id);
|
||||
mapper.deleteReviewsByVideoOnly(id);
|
||||
mapper.deleteFavoritesByVideoOnly(id);
|
||||
mapper.deleteRestaurantsByVideoOnly(id);
|
||||
mapper.deleteVideoRestaurants(id);
|
||||
mapper.deleteVideo(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteVideoRestaurant(String videoId, String restaurantId) {
|
||||
mapper.deleteOneVideoRestaurant(videoId, restaurantId);
|
||||
mapper.cleanupOrphanVectors(restaurantId);
|
||||
mapper.cleanupOrphanReviews(restaurantId);
|
||||
mapper.cleanupOrphanFavorites(restaurantId);
|
||||
mapper.cleanupOrphanRestaurant(restaurantId);
|
||||
}
|
||||
|
||||
public int saveVideosBatch(String channelId, List<Map<String, Object>> videos) {
|
||||
Set<String> existing = new HashSet<>(mapper.getExistingVideoIds(channelId));
|
||||
int saved = 0;
|
||||
for (var v : videos) {
|
||||
String videoId = (String) v.get("video_id");
|
||||
if (existing.contains(videoId)) continue;
|
||||
String id = IdGenerator.newId();
|
||||
mapper.insertVideo(id, channelId, videoId,
|
||||
(String) v.get("title"), (String) v.get("url"), (String) v.get("published_at"));
|
||||
saved++;
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
public Set<String> getExistingVideoIds(String channelId) {
|
||||
return new HashSet<>(mapper.getExistingVideoIds(channelId));
|
||||
}
|
||||
|
||||
public String getLatestVideoDate(String channelId) {
|
||||
return mapper.getLatestVideoDate(channelId);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findPendingVideos(int limit) {
|
||||
return mapper.findPendingVideos(limit).stream()
|
||||
.map(JsonUtil::lowerKeys).toList();
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findVideosForBulkExtract() {
|
||||
var rows = mapper.findVideosForBulkExtract();
|
||||
return rows.stream().map(row -> {
|
||||
var r = JsonUtil.lowerKeys(row);
|
||||
// Parse CLOB transcript
|
||||
Object transcript = r.get("transcript_text");
|
||||
r.put("transcript", JsonUtil.readClob(transcript));
|
||||
r.remove("transcript_text");
|
||||
return r;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
public void updateTranscript(String id, String transcript) {
|
||||
mapper.updateTranscript(id, transcript);
|
||||
}
|
||||
|
||||
public void updateVideoFields(String id, String status, String transcript, String llmResponse) {
|
||||
mapper.updateVideoFields(id, status, transcript, llmResponse);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findVideosWithoutTranscript() {
|
||||
var rows = mapper.findVideosWithoutTranscript();
|
||||
return rows.stream().map(JsonUtil::lowerKeys).toList();
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findVideosByIds(List<String> ids) {
|
||||
var rows = mapper.findVideosByIds(ids);
|
||||
return rows.stream().map(JsonUtil::lowerKeys).toList();
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findVideosForExtractByIds(List<String> ids) {
|
||||
var rows = mapper.findVideosForExtractByIds(ids);
|
||||
return rows.stream().map(row -> {
|
||||
var r = JsonUtil.lowerKeys(row);
|
||||
Object transcript = r.get("transcript_text");
|
||||
r.put("transcript", JsonUtil.readClob(transcript));
|
||||
r.remove("transcript_text");
|
||||
return r;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
public void updateVideoRestaurantFields(String videoId, String restaurantId,
|
||||
String foodsJson, String evaluation, String guestsJson) {
|
||||
mapper.updateVideoRestaurantFields(videoId, restaurantId, foodsJson, evaluation, guestsJson);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.github.thoroldvix.api.Transcript;
|
||||
import io.github.thoroldvix.api.TranscriptContent;
|
||||
import io.github.thoroldvix.api.TranscriptList;
|
||||
import io.github.thoroldvix.api.TranscriptApiFactory;
|
||||
import io.github.thoroldvix.api.YoutubeTranscriptApi;
|
||||
import com.microsoft.playwright.*;
|
||||
import com.microsoft.playwright.options.Cookie;
|
||||
import com.microsoft.playwright.options.WaitUntilState;
|
||||
import com.tasteby.domain.Channel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class YouTubeService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(YouTubeService.class);
|
||||
private static final Pattern DURATION_PATTERN = Pattern.compile("PT(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?");
|
||||
|
||||
private final WebClient webClient;
|
||||
private final ObjectMapper mapper;
|
||||
private final ChannelService channelService;
|
||||
private final VideoService videoService;
|
||||
private final String apiKey;
|
||||
|
||||
public YouTubeService(ObjectMapper mapper,
|
||||
ChannelService channelService,
|
||||
VideoService videoService,
|
||||
@Value("${app.google.youtube-api-key}") String apiKey) {
|
||||
this.webClient = WebClient.builder()
|
||||
.baseUrl("https://www.googleapis.com/youtube/v3")
|
||||
.build();
|
||||
this.mapper = mapper;
|
||||
this.channelService = channelService;
|
||||
this.videoService = videoService;
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch videos from a YouTube channel using the uploads playlist (UC→UU).
|
||||
* This returns ALL videos unlike the Search API which caps results.
|
||||
* Falls back to Search API if playlist approach fails.
|
||||
*/
|
||||
public List<Map<String, Object>> fetchChannelVideos(String channelId, String publishedAfter, boolean excludeShorts) {
|
||||
// Convert channel ID UC... → uploads playlist UU...
|
||||
String uploadsPlaylistId = "UU" + channelId.substring(2);
|
||||
List<Map<String, Object>> allVideos = new ArrayList<>();
|
||||
String nextPage = null;
|
||||
|
||||
try {
|
||||
do {
|
||||
String pageToken = nextPage;
|
||||
String response = webClient.get()
|
||||
.uri(uriBuilder -> {
|
||||
var b = uriBuilder.path("/playlistItems")
|
||||
.queryParam("key", apiKey)
|
||||
.queryParam("playlistId", uploadsPlaylistId)
|
||||
.queryParam("part", "snippet")
|
||||
.queryParam("maxResults", 50);
|
||||
if (pageToken != null) b.queryParam("pageToken", pageToken);
|
||||
return b.build();
|
||||
})
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofSeconds(30));
|
||||
|
||||
JsonNode data = mapper.readTree(response);
|
||||
List<Map<String, Object>> pageVideos = new ArrayList<>();
|
||||
|
||||
for (JsonNode item : data.path("items")) {
|
||||
JsonNode snippet = item.path("snippet");
|
||||
String vid = snippet.path("resourceId").path("videoId").asText();
|
||||
String publishedAt = snippet.path("publishedAt").asText();
|
||||
|
||||
// publishedAfter 필터: 이미 스캔한 영상 이후만
|
||||
if (publishedAfter != null && publishedAt.compareTo(publishedAfter) <= 0) {
|
||||
// 업로드 재생목록은 최신순이므로 이전 날짜 만나면 중단
|
||||
nextPage = null;
|
||||
break;
|
||||
}
|
||||
|
||||
pageVideos.add(Map.of(
|
||||
"video_id", vid,
|
||||
"title", snippet.path("title").asText(),
|
||||
"published_at", publishedAt,
|
||||
"url", "https://www.youtube.com/watch?v=" + vid
|
||||
));
|
||||
}
|
||||
|
||||
if (excludeShorts && !pageVideos.isEmpty()) {
|
||||
pageVideos = filterShorts(pageVideos);
|
||||
}
|
||||
allVideos.addAll(pageVideos);
|
||||
|
||||
if (nextPage != null || data.has("nextPageToken")) {
|
||||
nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null;
|
||||
}
|
||||
} while (nextPage != null);
|
||||
} catch (Exception e) {
|
||||
log.warn("PlaylistItems API failed for {}, falling back to Search API", channelId, e);
|
||||
return fetchChannelVideosViaSearch(channelId, publishedAfter, excludeShorts);
|
||||
}
|
||||
|
||||
return allVideos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: fetch via Search API (may not return all videos).
|
||||
*/
|
||||
private List<Map<String, Object>> fetchChannelVideosViaSearch(String channelId, String publishedAfter, boolean excludeShorts) {
|
||||
List<Map<String, Object>> allVideos = new ArrayList<>();
|
||||
String nextPage = null;
|
||||
|
||||
do {
|
||||
String pageToken = nextPage;
|
||||
String response = webClient.get()
|
||||
.uri(uriBuilder -> {
|
||||
var b = uriBuilder.path("/search")
|
||||
.queryParam("key", apiKey)
|
||||
.queryParam("channelId", channelId)
|
||||
.queryParam("part", "snippet")
|
||||
.queryParam("order", "date")
|
||||
.queryParam("maxResults", 50)
|
||||
.queryParam("type", "video");
|
||||
if (publishedAfter != null) b.queryParam("publishedAfter", publishedAfter);
|
||||
if (pageToken != null) b.queryParam("pageToken", pageToken);
|
||||
return b.build();
|
||||
})
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofSeconds(30));
|
||||
|
||||
try {
|
||||
JsonNode data = mapper.readTree(response);
|
||||
List<Map<String, Object>> pageVideos = new ArrayList<>();
|
||||
|
||||
for (JsonNode item : data.path("items")) {
|
||||
String vid = item.path("id").path("videoId").asText();
|
||||
JsonNode snippet = item.path("snippet");
|
||||
pageVideos.add(Map.of(
|
||||
"video_id", vid,
|
||||
"title", snippet.path("title").asText(),
|
||||
"published_at", snippet.path("publishedAt").asText(),
|
||||
"url", "https://www.youtube.com/watch?v=" + vid
|
||||
));
|
||||
}
|
||||
|
||||
if (excludeShorts && !pageVideos.isEmpty()) {
|
||||
pageVideos = filterShorts(pageVideos);
|
||||
}
|
||||
allVideos.addAll(pageVideos);
|
||||
|
||||
nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to parse YouTube Search API response", e);
|
||||
break;
|
||||
}
|
||||
} while (nextPage != null);
|
||||
|
||||
return allVideos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out YouTube Shorts (<=60s duration).
|
||||
* YouTube /videos API accepts max 50 IDs per request, so we batch.
|
||||
*/
|
||||
private List<Map<String, Object>> filterShorts(List<Map<String, Object>> videos) {
|
||||
Map<String, Integer> durations = new HashMap<>();
|
||||
List<String> allIds = videos.stream().map(v -> (String) v.get("video_id")).toList();
|
||||
|
||||
for (int i = 0; i < allIds.size(); i += 50) {
|
||||
List<String> batch = allIds.subList(i, Math.min(i + 50, allIds.size()));
|
||||
String ids = String.join(",", batch);
|
||||
try {
|
||||
String response = webClient.get()
|
||||
.uri(uriBuilder -> uriBuilder.path("/videos")
|
||||
.queryParam("key", apiKey)
|
||||
.queryParam("id", ids)
|
||||
.queryParam("part", "contentDetails")
|
||||
.build())
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block(Duration.ofSeconds(30));
|
||||
|
||||
JsonNode data = mapper.readTree(response);
|
||||
for (JsonNode item : data.path("items")) {
|
||||
String duration = item.path("contentDetails").path("duration").asText();
|
||||
durations.put(item.path("id").asText(), parseDuration(duration));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to fetch video durations for batch starting at {}", i, e);
|
||||
}
|
||||
}
|
||||
|
||||
return videos.stream()
|
||||
.filter(v -> durations.getOrDefault(v.get("video_id"), 61) > 60)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private int parseDuration(String dur) {
|
||||
Matcher m = DURATION_PATTERN.matcher(dur != null ? dur : "");
|
||||
if (!m.matches()) return 0;
|
||||
int h = m.group(1) != null ? Integer.parseInt(m.group(1)) : 0;
|
||||
int min = m.group(2) != null ? Integer.parseInt(m.group(2)) : 0;
|
||||
int s = m.group(3) != null ? Integer.parseInt(m.group(3)) : 0;
|
||||
return h * 3600 + min * 60 + s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a single channel for new videos. Returns scan result map.
|
||||
*/
|
||||
public Map<String, Object> scanChannel(String channelId, boolean full) {
|
||||
Channel ch = channelService.findByChannelId(channelId);
|
||||
if (ch == null) return null;
|
||||
|
||||
String dbId = ch.getId();
|
||||
String titleFilter = ch.getTitleFilter();
|
||||
String after = full ? null : videoService.getLatestVideoDate(dbId);
|
||||
Set<String> existing = videoService.getExistingVideoIds(dbId);
|
||||
|
||||
List<Map<String, Object>> allFetched = fetchChannelVideos(channelId, after, true);
|
||||
int totalFetched = allFetched.size();
|
||||
|
||||
List<Map<String, Object>> candidates = new ArrayList<>();
|
||||
for (var v : allFetched) {
|
||||
if (titleFilter != null && !((String) v.get("title")).contains(titleFilter)) continue;
|
||||
if (existing.contains(v.get("video_id"))) continue;
|
||||
candidates.add(v);
|
||||
}
|
||||
|
||||
int newCount = videoService.saveVideosBatch(dbId, candidates);
|
||||
return Map.of(
|
||||
"total_fetched", totalFetched,
|
||||
"new_videos", newCount,
|
||||
"filtered", titleFilter != null ? totalFetched - candidates.size() : 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan all active channels. Returns total new video count.
|
||||
*/
|
||||
public int scanAllChannels() {
|
||||
List<Channel> channels = channelService.findAllActive();
|
||||
int totalNew = 0;
|
||||
for (var ch : channels) {
|
||||
try {
|
||||
var result = scanChannel(ch.getChannelId(), false);
|
||||
if (result != null) {
|
||||
totalNew += ((Number) result.get("new_videos")).intValue();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to scan channel {}: {}", ch.getChannelName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
return totalNew;
|
||||
}
|
||||
|
||||
public record TranscriptResult(String text, String source) {}
|
||||
|
||||
private static final List<String> PREFERRED_LANGS = List.of("ko", "en");
|
||||
private final YoutubeTranscriptApi transcriptApi = TranscriptApiFactory.createDefault();
|
||||
|
||||
/**
|
||||
* Fetch transcript for a YouTube video.
|
||||
* Tries API first (fast), then falls back to Playwright browser extraction.
|
||||
* @param mode "auto" = manual first then generated, "manual" = manual only, "generated" = generated only
|
||||
*/
|
||||
public TranscriptResult getTranscript(String videoId, String mode) {
|
||||
if (mode == null) mode = "auto";
|
||||
|
||||
// 1) Playwright headed browser (봇 판정 회피)
|
||||
TranscriptResult browserResult = getTranscriptBrowser(videoId);
|
||||
if (browserResult != null) return browserResult;
|
||||
|
||||
// 2) Fallback: youtube-transcript-api
|
||||
log.warn("Browser failed for {}, trying API", videoId);
|
||||
return getTranscriptApi(videoId, mode);
|
||||
}
|
||||
|
||||
public 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 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch transcript using an existing Playwright Page (for bulk reuse).
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public TranscriptResult getTranscriptWithPage(Page page, String videoId) {
|
||||
return fetchTranscriptFromPage(page, videoId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Playwright browser + context + page for transcript fetching.
|
||||
* Caller must close the returned resources (Playwright, Browser).
|
||||
*/
|
||||
public record BrowserSession(Playwright playwright, Browser browser, Page page) implements AutoCloseable {
|
||||
@Override
|
||||
public void close() {
|
||||
try { browser.close(); } catch (Exception ignored) {}
|
||||
try { playwright.close(); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
public BrowserSession createBrowserSession() {
|
||||
Playwright pw = Playwright.create();
|
||||
Browser browser = pw.chromium().launch(new BrowserType.LaunchOptions()
|
||||
.setHeadless(false)
|
||||
.setArgs(List.of("--disable-blink-features=AutomationControlled")));
|
||||
BrowserContext ctx = browser.newContext(new Browser.NewContextOptions()
|
||||
.setLocale("ko-KR")
|
||||
.setViewportSize(1280, 900));
|
||||
loadCookies(ctx);
|
||||
Page page = ctx.newPage();
|
||||
page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})");
|
||||
return new BrowserSession(pw, browser, page);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private TranscriptResult getTranscriptBrowser(String videoId) {
|
||||
try (BrowserSession session = createBrowserSession()) {
|
||||
return fetchTranscriptFromPage(session.page(), videoId);
|
||||
} catch (Exception e) {
|
||||
log.error("[TRANSCRIPT] Playwright failed for {}: {}", videoId, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private TranscriptResult fetchTranscriptFromPage(Page page, String videoId) {
|
||||
try {
|
||||
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(3000);
|
||||
|
||||
skipAds(page);
|
||||
|
||||
page.waitForTimeout(1000);
|
||||
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 ~15s)
|
||||
page.waitForTimeout(2000);
|
||||
for (int attempt = 0; attempt < 10; attempt++) {
|
||||
page.waitForTimeout(1500);
|
||||
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) * 1.5 + 2, segCount);
|
||||
if (segCount > 0) break;
|
||||
}
|
||||
|
||||
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] Page fetch failed for {}: {}", videoId, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void skipAds(Page page) {
|
||||
for (int i = 0; i < 30; 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) {
|
||||
// 광고 중: 뮤트 + 끝으로 이동 시도
|
||||
const video = document.querySelector('video');
|
||||
if (video) {
|
||||
video.muted = true;
|
||||
if (video.duration && isFinite(video.duration)) {
|
||||
video.currentTime = video.duration;
|
||||
}
|
||||
}
|
||||
return 'playing';
|
||||
}
|
||||
const adBadge = document.querySelector('.ytp-ad-text');
|
||||
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(1000);
|
||||
break;
|
||||
}
|
||||
page.waitForTimeout(1000);
|
||||
}
|
||||
}
|
||||
|
||||
private void selectKorean(Page page) {
|
||||
page.evaluate("""
|
||||
() => {
|
||||
const menu = document.querySelector('ytd-transcript-renderer ytd-menu-renderer yt-dropdown-menu');
|
||||
if (!menu) return;
|
||||
const trigger = menu.querySelector('button, tp-yt-paper-button');
|
||||
if (trigger) trigger.click();
|
||||
}
|
||||
""");
|
||||
page.waitForTimeout(1000);
|
||||
page.evaluate("""
|
||||
() => {
|
||||
const items = document.querySelectorAll('tp-yt-paper-listbox a, tp-yt-paper-listbox tp-yt-paper-item');
|
||||
for (const item of items) {
|
||||
const text = item.textContent.trim();
|
||||
if (text.includes('한국어') || text.includes('Korean')) {
|
||||
item.click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
private void loadCookies(BrowserContext ctx) {
|
||||
try {
|
||||
Path cookieFile = Path.of(System.getProperty("user.dir"), "cookies.txt");
|
||||
if (!cookieFile.toFile().exists()) return;
|
||||
|
||||
List<String> lines = java.nio.file.Files.readAllLines(cookieFile);
|
||||
List<Cookie> cookies = new ArrayList<>();
|
||||
for (String line : lines) {
|
||||
if (line.startsWith("#") || line.isBlank()) continue;
|
||||
String[] parts = line.split("\t");
|
||||
if (parts.length < 7) continue;
|
||||
String domain = parts[0];
|
||||
if (!domain.contains("youtube") && !domain.contains("google")) continue;
|
||||
cookies.add(new Cookie(parts[5], parts[6])
|
||||
.setDomain(domain)
|
||||
.setPath(parts[2])
|
||||
.setSecure("TRUE".equalsIgnoreCase(parts[3]))
|
||||
.setHttpOnly(false));
|
||||
}
|
||||
if (!cookies.isEmpty()) {
|
||||
ctx.addCookies(cookies);
|
||||
log.info("[TRANSCRIPT] Loaded {} cookies", cookies.size());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to load cookies: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.tasteby.util;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Standardized cuisine type taxonomy.
|
||||
*/
|
||||
public final class CuisineTypes {
|
||||
|
||||
private CuisineTypes() {}
|
||||
|
||||
public static final List<String> ALL = List.of(
|
||||
"한식|백반/한정식", "한식|국밥/해장국", "한식|찌개/전골/탕", "한식|삼겹살/돼지구이",
|
||||
"한식|소고기/한우구이", "한식|곱창/막창", "한식|닭/오리구이", "한식|족발/보쌈",
|
||||
"한식|회/횟집", "한식|해산물", "한식|분식", "한식|면", "한식|죽/죽집",
|
||||
"한식|순대/순대국", "한식|장어/민물", "한식|주점/포차", "한식|파인다이닝/코스",
|
||||
"일식|스시/오마카세", "일식|라멘", "일식|돈카츠", "일식|텐동/튀김",
|
||||
"일식|이자카야", "일식|야키니쿠", "일식|카레", "일식|소바/우동", "일식|파인다이닝/코스",
|
||||
"중식|중화요리", "중식|마라/훠궈", "중식|딤섬/만두", "중식|양꼬치", "중식|파인다이닝/코스",
|
||||
"양식|파스타/이탈리안", "양식|스테이크", "양식|햄버거", "양식|피자",
|
||||
"양식|프렌치", "양식|바베큐", "양식|브런치", "양식|비건/샐러드", "양식|파인다이닝/코스",
|
||||
"아시아|베트남", "아시아|태국", "아시아|인도/중동", "아시아|동남아기타",
|
||||
"기타|치킨", "기타|카페/디저트", "기타|베이커리", "기타|뷔페", "기타|퓨전"
|
||||
);
|
||||
|
||||
public static final Set<String> VALID_SET = Set.copyOf(ALL);
|
||||
|
||||
public static final List<String> VALID_PREFIXES = List.of(
|
||||
"한식|", "일식|", "중식|", "양식|", "아시아|", "기타|"
|
||||
);
|
||||
|
||||
public static final String CUISINE_LIST_TEXT = ALL.stream()
|
||||
.map(c -> " - " + c)
|
||||
.collect(Collectors.joining("\n"));
|
||||
|
||||
public static boolean isValid(String cuisineType) {
|
||||
if (VALID_SET.contains(cuisineType)) return true;
|
||||
return VALID_PREFIXES.stream().anyMatch(cuisineType::startsWith);
|
||||
}
|
||||
}
|
||||
12
backend-java/src/main/java/com/tasteby/util/IdGenerator.java
Normal file
12
backend-java/src/main/java/com/tasteby/util/IdGenerator.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.tasteby.util;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public final class IdGenerator {
|
||||
|
||||
private IdGenerator() {}
|
||||
|
||||
public static String newId() {
|
||||
return UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase();
|
||||
}
|
||||
}
|
||||
84
backend-java/src/main/java/com/tasteby/util/JsonUtil.java
Normal file
84
backend-java/src/main/java/com/tasteby/util/JsonUtil.java
Normal file
@@ -0,0 +1,84 @@
|
||||
package com.tasteby.util;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.Reader;
|
||||
import java.sql.Clob;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* JSON utility for handling Oracle CLOB JSON fields.
|
||||
*/
|
||||
public final class JsonUtil {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private JsonUtil() {}
|
||||
|
||||
public static String readClob(Object val) {
|
||||
if (val == null) return null;
|
||||
if (val instanceof String s) return s;
|
||||
if (val instanceof Clob clob) {
|
||||
try (Reader reader = clob.getCharacterStream()) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
char[] buf = new char[4096];
|
||||
int len;
|
||||
while ((len = reader.read(buf)) != -1) {
|
||||
sb.append(buf, 0, len);
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return val.toString();
|
||||
}
|
||||
|
||||
public static List<String> parseStringList(Object raw) {
|
||||
String json = readClob(raw);
|
||||
if (json == null || json.isBlank()) return Collections.emptyList();
|
||||
try {
|
||||
return MAPPER.readValue(json, new TypeReference<>() {});
|
||||
} catch (Exception e) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
public static Map<String, Object> parseMap(Object raw) {
|
||||
String json = readClob(raw);
|
||||
if (json == null || json.isBlank()) return Collections.emptyMap();
|
||||
try {
|
||||
return MAPPER.readValue(json, new TypeReference<>() {});
|
||||
} catch (Exception e) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Oracle uppercase column keys to lowercase (e.g. "ID" → "id", "CUISINE_TYPE" → "cuisine_type").
|
||||
*/
|
||||
public static Map<String, Object> lowerKeys(Map<String, Object> row) {
|
||||
if (row == null) return null;
|
||||
var result = new LinkedHashMap<String, Object>(row.size());
|
||||
for (var entry : row.entrySet()) {
|
||||
result.put(entry.getKey().toLowerCase(), entry.getValue());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static List<Map<String, Object>> lowerKeys(List<Map<String, Object>> rows) {
|
||||
if (rows == null) return null;
|
||||
return rows.stream().map(JsonUtil::lowerKeys).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public static String toJson(Object value) {
|
||||
try {
|
||||
return MAPPER.writeValueAsString(value);
|
||||
} catch (JsonProcessingException e) {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.tasteby.util;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Parse address into "country|city|district" format.
|
||||
*/
|
||||
public final class RegionParser {
|
||||
|
||||
private RegionParser() {}
|
||||
|
||||
private static final Map<String, String> CITY_MAP = Map.ofEntries(
|
||||
Map.entry("서울특별시", "서울"), Map.entry("서울", "서울"),
|
||||
Map.entry("부산광역시", "부산"), Map.entry("부산", "부산"),
|
||||
Map.entry("대구광역시", "대구"), Map.entry("대구", "대구"),
|
||||
Map.entry("인천광역시", "인천"), Map.entry("인천", "인천"),
|
||||
Map.entry("광주광역시", "광주"), Map.entry("광주", "광주"),
|
||||
Map.entry("대전광역시", "대전"), Map.entry("대전", "대전"),
|
||||
Map.entry("울산광역시", "울산"), Map.entry("울산", "울산"),
|
||||
Map.entry("세종특별자치시", "세종"),
|
||||
Map.entry("경기도", "경기"), Map.entry("경기", "경기"),
|
||||
Map.entry("강원특별자치도", "강원"), Map.entry("강원도", "강원"),
|
||||
Map.entry("충청북도", "충북"), Map.entry("충청남도", "충남"),
|
||||
Map.entry("전라북도", "전북"), Map.entry("전북특별자치도", "전북"),
|
||||
Map.entry("전라남도", "전남"),
|
||||
Map.entry("경상북도", "경북"), Map.entry("경상남도", "경남"),
|
||||
Map.entry("제주특별자치도", "제주")
|
||||
);
|
||||
|
||||
private static final Pattern KR_PATTERN = Pattern.compile("대한민국\\s+(\\S+)\\s+(\\S+)");
|
||||
|
||||
public static String parse(String address) {
|
||||
if (address == null || address.isBlank()) return null;
|
||||
String addr = address.trim();
|
||||
|
||||
// Japanese
|
||||
if (addr.startsWith("일본") || addr.contains("Japan")) {
|
||||
String city = null;
|
||||
if (addr.contains("Tokyo")) city = "도쿄";
|
||||
else if (addr.contains("Osaka")) city = "오사카";
|
||||
else if (addr.contains("Sapporo") || addr.contains("Hokkaido")) city = "삿포로";
|
||||
else if (addr.contains("Kyoto")) city = "교토";
|
||||
else if (addr.contains("Fukuoka")) city = "후쿠오카";
|
||||
return city != null ? "일본|" + city : "일본";
|
||||
}
|
||||
|
||||
// Singapore
|
||||
if (addr.contains("Singapore") || addr.contains("싱가포르")) {
|
||||
return "싱가포르";
|
||||
}
|
||||
|
||||
// Korean: "대한민국 시/도 구/시 ..."
|
||||
if (addr.contains("대한민국")) {
|
||||
Matcher m = KR_PATTERN.matcher(addr);
|
||||
if (m.find()) {
|
||||
String city = CITY_MAP.get(m.group(1));
|
||||
if (city != null) {
|
||||
String gu = m.group(2);
|
||||
if (gu.endsWith("구") || gu.endsWith("군") || gu.endsWith("시")) {
|
||||
return "한국|" + city + "|" + gu;
|
||||
}
|
||||
return "한국|" + city;
|
||||
}
|
||||
}
|
||||
// Search parts
|
||||
String[] parts = addr.split("\\s+");
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
String city = CITY_MAP.get(parts[i]);
|
||||
if (city != null) {
|
||||
String gu = (i > 0 && (parts[i - 1].endsWith("구") || parts[i - 1].endsWith("군") || parts[i - 1].endsWith("시")))
|
||||
? parts[i - 1] : null;
|
||||
return gu != null ? "한국|" + city + "|" + gu : "한국|" + city;
|
||||
}
|
||||
}
|
||||
return "한국";
|
||||
}
|
||||
|
||||
// Korean without prefix
|
||||
String[] parts = addr.split("\\s+");
|
||||
if (parts.length > 0) {
|
||||
String city = CITY_MAP.get(parts[0]);
|
||||
if (city != null) {
|
||||
if (parts.length > 1 && (parts[1].endsWith("구") || parts[1].endsWith("군") || parts[1].endsWith("시"))) {
|
||||
return "한국|" + city + "|" + parts[1];
|
||||
}
|
||||
return "한국|" + city;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
71
backend-java/src/main/resources/application.yml
Normal file
71
backend-java/src/main/resources/application.yml
Normal file
@@ -0,0 +1,71 @@
|
||||
server:
|
||||
port: 8000
|
||||
|
||||
spring:
|
||||
threads:
|
||||
virtual:
|
||||
enabled: true
|
||||
|
||||
datasource:
|
||||
url: jdbc:oracle:thin:@${ORACLE_DSN}
|
||||
username: ${ORACLE_USER}
|
||||
password: ${ORACLE_PASSWORD}
|
||||
driver-class-name: oracle.jdbc.OracleDriver
|
||||
hikari:
|
||||
minimum-idle: 2
|
||||
maximum-pool-size: 10
|
||||
connection-timeout: 30000
|
||||
idle-timeout: 600000
|
||||
max-lifetime: 1800000
|
||||
pool-name: TastebyHikariPool
|
||||
connection-init-sql: "SELECT 1 FROM DUAL"
|
||||
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:192.168.0.147}
|
||||
port: ${REDIS_PORT:6379}
|
||||
database: ${REDIS_DB:0}
|
||||
timeout: 2000ms
|
||||
|
||||
jackson:
|
||||
default-property-inclusion: non_null
|
||||
property-naming-strategy: SNAKE_CASE
|
||||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
|
||||
app:
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:tasteby-dev-secret-change-me}
|
||||
expiration-days: 7
|
||||
|
||||
cors:
|
||||
allowed-origins: http://localhost:3000,http://localhost:3001,https://www.tasteby.net,https://tasteby.net,https://dev.tasteby.net
|
||||
|
||||
oracle:
|
||||
wallet-path: ${ORACLE_WALLET:}
|
||||
|
||||
oci:
|
||||
compartment-id: ${OCI_COMPARTMENT_ID}
|
||||
chat-endpoint: ${OCI_CHAT_ENDPOINT:https://inference.generativeai.us-ashburn-1.oci.oraclecloud.com}
|
||||
embed-endpoint: ${OCI_GENAI_ENDPOINT:https://inference.generativeai.us-chicago-1.oci.oraclecloud.com}
|
||||
chat-model-id: ${OCI_CHAT_MODEL_ID}
|
||||
embed-model-id: ${OCI_EMBED_MODEL_ID:cohere.embed-v4.0}
|
||||
|
||||
google:
|
||||
maps-api-key: ${GOOGLE_MAPS_API_KEY}
|
||||
youtube-api-key: ${YOUTUBE_DATA_API_KEY}
|
||||
client-id: ${GOOGLE_CLIENT_ID:635551099330-2l003d3ernjmkqavd4f6s78r8r405iml.apps.googleusercontent.com}
|
||||
|
||||
cache:
|
||||
ttl-seconds: 600
|
||||
|
||||
mybatis:
|
||||
mapper-locations: classpath:mybatis/mapper/*.xml
|
||||
config-location: classpath:mybatis/mybatis-config.xml
|
||||
type-aliases-package: com.tasteby.domain
|
||||
type-handlers-package: com.tasteby.config
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.tasteby: DEBUG
|
||||
com.tasteby.mapper: DEBUG
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.tasteby.mapper.ChannelMapper">
|
||||
|
||||
<resultMap id="channelResultMap" type="com.tasteby.domain.Channel">
|
||||
<id property="id" column="id"/>
|
||||
<result property="channelId" column="channel_id"/>
|
||||
<result property="channelName" column="channel_name"/>
|
||||
<result property="titleFilter" column="title_filter"/>
|
||||
<result property="videoCount" column="video_count"/>
|
||||
<result property="lastVideoAt" column="last_video_at"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="findAllActive" resultMap="channelResultMap">
|
||||
SELECT c.id, c.channel_id, c.channel_name, c.title_filter,
|
||||
(SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id) AS video_count,
|
||||
(SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_video_at
|
||||
FROM channels c
|
||||
WHERE c.is_active = 1
|
||||
ORDER BY c.channel_name
|
||||
</select>
|
||||
|
||||
<insert id="insert">
|
||||
INSERT INTO channels (id, channel_id, channel_name, title_filter)
|
||||
VALUES (#{id}, #{channelId}, #{channelName}, #{titleFilter})
|
||||
</insert>
|
||||
|
||||
<update id="deactivateByChannelId">
|
||||
UPDATE channels SET is_active = 0
|
||||
WHERE channel_id = #{channelId} AND is_active = 1
|
||||
</update>
|
||||
|
||||
<update id="deactivateById">
|
||||
UPDATE channels SET is_active = 0
|
||||
WHERE id = #{id} AND is_active = 1
|
||||
</update>
|
||||
|
||||
<select id="findByChannelId" resultMap="channelResultMap">
|
||||
SELECT id, channel_id, channel_name, title_filter
|
||||
FROM channels
|
||||
WHERE channel_id = #{channelId} AND is_active = 1
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.tasteby.mapper.DaemonConfigMapper">
|
||||
|
||||
<resultMap id="daemonConfigResultMap" type="com.tasteby.domain.DaemonConfig">
|
||||
<id property="id" column="id"/>
|
||||
<result property="scanEnabled" column="scan_enabled" javaType="boolean"/>
|
||||
<result property="scanIntervalMin" column="scan_interval_min"/>
|
||||
<result property="processEnabled" column="process_enabled" javaType="boolean"/>
|
||||
<result property="processIntervalMin" column="process_interval_min"/>
|
||||
<result property="processLimit" column="process_limit"/>
|
||||
<result property="lastScanAt" column="last_scan_at"/>
|
||||
<result property="lastProcessAt" column="last_process_at"/>
|
||||
<result property="updatedAt" column="updated_at"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="getConfig" resultMap="daemonConfigResultMap">
|
||||
SELECT id, scan_enabled, scan_interval_min, process_enabled, process_interval_min,
|
||||
process_limit, last_scan_at, last_process_at, updated_at
|
||||
FROM daemon_config
|
||||
WHERE id = 1
|
||||
</select>
|
||||
|
||||
<update id="updateConfig">
|
||||
UPDATE daemon_config
|
||||
<set>
|
||||
scan_enabled = #{scanEnabled, javaType=boolean, jdbcType=NUMERIC},
|
||||
scan_interval_min = #{scanIntervalMin},
|
||||
process_enabled = #{processEnabled, javaType=boolean, jdbcType=NUMERIC},
|
||||
process_interval_min = #{processIntervalMin},
|
||||
process_limit = #{processLimit},
|
||||
updated_at = SYSTIMESTAMP,
|
||||
</set>
|
||||
WHERE id = 1
|
||||
</update>
|
||||
|
||||
<update id="updateLastScan">
|
||||
UPDATE daemon_config SET last_scan_at = SYSTIMESTAMP WHERE id = 1
|
||||
</update>
|
||||
|
||||
<update id="updateLastProcess">
|
||||
UPDATE daemon_config SET last_process_at = SYSTIMESTAMP WHERE id = 1
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,272 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.tasteby.mapper.RestaurantMapper">
|
||||
|
||||
<!-- ===== Result Maps ===== -->
|
||||
|
||||
<resultMap id="restaurantMap" type="Restaurant">
|
||||
<id property="id" column="id"/>
|
||||
<result property="name" column="name"/>
|
||||
<result property="address" column="address"/>
|
||||
<result property="region" column="region"/>
|
||||
<result property="latitude" column="latitude"/>
|
||||
<result property="longitude" column="longitude"/>
|
||||
<result property="cuisineType" column="cuisine_type"/>
|
||||
<result property="priceRange" column="price_range"/>
|
||||
<result property="phone" column="phone"/>
|
||||
<result property="website" column="website"/>
|
||||
<result property="googlePlaceId" column="google_place_id"/>
|
||||
<result property="tablingUrl" column="tabling_url"/>
|
||||
<result property="catchtableUrl" column="catchtable_url"/>
|
||||
<result property="businessStatus" column="business_status"/>
|
||||
<result property="rating" column="rating"/>
|
||||
<result property="ratingCount" column="rating_count"/>
|
||||
<result property="updatedAt" column="updated_at"/>
|
||||
</resultMap>
|
||||
|
||||
<!-- ===== Queries ===== -->
|
||||
|
||||
<select id="findAll" resultMap="restaurantMap">
|
||||
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
||||
r.cuisine_type, r.price_range, r.google_place_id, r.tabling_url, r.catchtable_url,
|
||||
r.business_status, r.rating, r.rating_count, r.updated_at
|
||||
FROM restaurants r
|
||||
<if test="channel != null and channel != ''">
|
||||
JOIN video_restaurants vr_f ON vr_f.restaurant_id = r.id
|
||||
JOIN videos v_f ON v_f.id = vr_f.video_id
|
||||
JOIN channels c_f ON c_f.id = v_f.channel_id
|
||||
</if>
|
||||
<where>
|
||||
r.latitude IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM video_restaurants vr0 WHERE vr0.restaurant_id = r.id)
|
||||
<if test="cuisine != null and cuisine != ''">
|
||||
AND r.cuisine_type = #{cuisine}
|
||||
</if>
|
||||
<if test="region != null and region != ''">
|
||||
AND r.region LIKE '%' || #{region} || '%'
|
||||
</if>
|
||||
<if test="channel != null and channel != ''">
|
||||
AND c_f.channel_name = #{channel}
|
||||
</if>
|
||||
</where>
|
||||
ORDER BY r.updated_at DESC
|
||||
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
|
||||
</select>
|
||||
|
||||
<select id="findById" resultMap="restaurantMap">
|
||||
SELECT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
||||
r.cuisine_type, r.price_range, r.phone, r.website, r.google_place_id,
|
||||
r.tabling_url, r.catchtable_url, r.business_status, r.rating, r.rating_count
|
||||
FROM restaurants r
|
||||
WHERE r.id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="findVideoLinks" resultType="map">
|
||||
SELECT v.video_id, v.title, v.url,
|
||||
TO_CHAR(v.published_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS published_at,
|
||||
vr.foods_mentioned, vr.evaluation, vr.guests,
|
||||
c.channel_name, c.channel_id
|
||||
FROM video_restaurants vr
|
||||
JOIN videos v ON v.id = vr.video_id
|
||||
JOIN channels c ON c.id = v.channel_id
|
||||
WHERE vr.restaurant_id = #{restaurantId}
|
||||
ORDER BY v.published_at DESC
|
||||
</select>
|
||||
|
||||
<!-- ===== Insert ===== -->
|
||||
|
||||
<insert id="insertRestaurant">
|
||||
INSERT INTO restaurants (id, name, address, region, latitude, longitude,
|
||||
cuisine_type, price_range, google_place_id,
|
||||
phone, website, business_status, rating, rating_count)
|
||||
VALUES (#{id}, #{name}, #{address}, #{region}, #{latitude}, #{longitude},
|
||||
#{cuisineType}, #{priceRange}, #{googlePlaceId},
|
||||
#{phone}, #{website}, #{businessStatus}, #{rating}, #{ratingCount})
|
||||
</insert>
|
||||
|
||||
<!-- ===== Update with COALESCE ===== -->
|
||||
|
||||
<update id="updateRestaurant">
|
||||
UPDATE restaurants SET
|
||||
name = #{name},
|
||||
address = COALESCE(#{address}, address),
|
||||
region = COALESCE(#{region}, region),
|
||||
latitude = COALESCE(#{latitude}, latitude),
|
||||
longitude = COALESCE(#{longitude}, longitude),
|
||||
cuisine_type = COALESCE(#{cuisineType}, cuisine_type),
|
||||
price_range = COALESCE(#{priceRange}, price_range),
|
||||
google_place_id = COALESCE(#{googlePlaceId}, google_place_id),
|
||||
phone = COALESCE(#{phone}, phone),
|
||||
website = COALESCE(#{website}, website),
|
||||
business_status = COALESCE(#{businessStatus}, business_status),
|
||||
rating = COALESCE(#{rating}, rating),
|
||||
rating_count = COALESCE(#{ratingCount}, rating_count),
|
||||
updated_at = SYSTIMESTAMP
|
||||
WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
<!-- ===== Dynamic field update ===== -->
|
||||
|
||||
<update id="updateFields">
|
||||
UPDATE restaurants SET
|
||||
<trim suffixOverrides=",">
|
||||
<if test="fields.containsKey('name')">
|
||||
name = #{fields.name},
|
||||
</if>
|
||||
<if test="fields.containsKey('address')">
|
||||
address = #{fields.address},
|
||||
</if>
|
||||
<if test="fields.containsKey('region')">
|
||||
region = #{fields.region},
|
||||
</if>
|
||||
<if test="fields.containsKey('cuisine_type')">
|
||||
cuisine_type = #{fields.cuisine_type},
|
||||
</if>
|
||||
<if test="fields.containsKey('price_range')">
|
||||
price_range = #{fields.price_range},
|
||||
</if>
|
||||
<if test="fields.containsKey('phone')">
|
||||
phone = #{fields.phone},
|
||||
</if>
|
||||
<if test="fields.containsKey('website')">
|
||||
website = #{fields.website},
|
||||
</if>
|
||||
<if test="fields.containsKey('tabling_url')">
|
||||
tabling_url = #{fields.tabling_url},
|
||||
</if>
|
||||
<if test="fields.containsKey('catchtable_url')">
|
||||
catchtable_url = #{fields.catchtable_url},
|
||||
</if>
|
||||
<if test="fields.containsKey('latitude')">
|
||||
latitude = #{fields.latitude},
|
||||
</if>
|
||||
<if test="fields.containsKey('longitude')">
|
||||
longitude = #{fields.longitude},
|
||||
</if>
|
||||
<if test="fields.containsKey('google_place_id')">
|
||||
google_place_id = #{fields.google_place_id},
|
||||
</if>
|
||||
<if test="fields.containsKey('business_status')">
|
||||
business_status = #{fields.business_status},
|
||||
</if>
|
||||
<if test="fields.containsKey('rating')">
|
||||
rating = #{fields.rating},
|
||||
</if>
|
||||
<if test="fields.containsKey('rating_count')">
|
||||
rating_count = #{fields.rating_count},
|
||||
</if>
|
||||
updated_at = SYSTIMESTAMP,
|
||||
</trim>
|
||||
WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
<!-- ===== Cascade deletes ===== -->
|
||||
|
||||
<delete id="deleteVectors">
|
||||
DELETE FROM restaurant_vectors WHERE restaurant_id = #{id}
|
||||
</delete>
|
||||
|
||||
<delete id="deleteReviews">
|
||||
DELETE FROM user_reviews WHERE restaurant_id = #{id}
|
||||
</delete>
|
||||
|
||||
<delete id="deleteFavorites">
|
||||
DELETE FROM user_favorites WHERE restaurant_id = #{id}
|
||||
</delete>
|
||||
|
||||
<delete id="deleteVideoRestaurants">
|
||||
DELETE FROM video_restaurants WHERE restaurant_id = #{id}
|
||||
</delete>
|
||||
|
||||
<delete id="deleteRestaurant">
|
||||
DELETE FROM restaurants WHERE id = #{id}
|
||||
</delete>
|
||||
|
||||
<!-- ===== Link video-restaurant ===== -->
|
||||
|
||||
<insert id="linkVideoRestaurant">
|
||||
INSERT INTO video_restaurants (id, video_id, restaurant_id, foods_mentioned, evaluation, guests)
|
||||
VALUES (#{id}, #{videoId}, #{restaurantId}, #{foods,jdbcType=CLOB}, #{evaluation,jdbcType=CLOB}, #{guests,jdbcType=CLOB})
|
||||
</insert>
|
||||
|
||||
<!-- ===== Lookups ===== -->
|
||||
|
||||
<select id="findIdByPlaceId" resultType="string">
|
||||
SELECT id FROM restaurants WHERE google_place_id = #{placeId} FETCH FIRST 1 ROWS ONLY
|
||||
</select>
|
||||
|
||||
<select id="findIdByName" resultType="string">
|
||||
SELECT id FROM restaurants WHERE name = #{name} FETCH FIRST 1 ROWS ONLY
|
||||
</select>
|
||||
|
||||
<!-- ===== Batch enrichment queries ===== -->
|
||||
|
||||
<select id="findChannelsByRestaurantIds" resultType="map">
|
||||
SELECT DISTINCT vr.restaurant_id, c.channel_name
|
||||
FROM video_restaurants vr
|
||||
JOIN videos v ON v.id = vr.video_id
|
||||
JOIN channels c ON c.id = v.channel_id
|
||||
WHERE vr.restaurant_id IN
|
||||
<foreach item="id" collection="ids" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
</select>
|
||||
|
||||
<select id="findFoodsByRestaurantIds" resultType="map">
|
||||
SELECT vr.restaurant_id, vr.foods_mentioned
|
||||
FROM video_restaurants vr
|
||||
WHERE vr.restaurant_id IN
|
||||
<foreach item="id" collection="ids" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
</select>
|
||||
|
||||
<select id="findWithoutTabling" resultMap="restaurantMap">
|
||||
SELECT r.id, r.name, r.address, r.region
|
||||
FROM restaurants r
|
||||
WHERE r.tabling_url IS NULL
|
||||
AND r.latitude IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM video_restaurants vr WHERE vr.restaurant_id = r.id)
|
||||
ORDER BY r.name
|
||||
</select>
|
||||
|
||||
<select id="findWithoutCatchtable" resultMap="restaurantMap">
|
||||
SELECT r.id, r.name, r.address, r.region
|
||||
FROM restaurants r
|
||||
WHERE r.catchtable_url IS NULL
|
||||
AND r.latitude IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM video_restaurants vr WHERE vr.restaurant_id = r.id)
|
||||
ORDER BY r.name
|
||||
</select>
|
||||
|
||||
<!-- ===== Remap operations ===== -->
|
||||
|
||||
<update id="updateCuisineType">
|
||||
UPDATE restaurants SET cuisine_type = #{cuisineType} WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
<update id="updateFoodsMentioned">
|
||||
UPDATE video_restaurants SET foods_mentioned = #{foods,jdbcType=CLOB} WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
<select id="findForRemapCuisine" resultType="map">
|
||||
SELECT r.id, r.name, r.cuisine_type,
|
||||
(SELECT LISTAGG(TO_CHAR(DBMS_LOB.SUBSTR(vr.foods_mentioned, 500, 1)), '|')
|
||||
WITHIN GROUP (ORDER BY vr.id)
|
||||
FROM video_restaurants vr WHERE vr.restaurant_id = r.id) AS foods
|
||||
FROM restaurants r
|
||||
WHERE EXISTS (SELECT 1 FROM video_restaurants vr2 WHERE vr2.restaurant_id = r.id)
|
||||
ORDER BY r.name
|
||||
</select>
|
||||
|
||||
<select id="findForRemapFoods" resultType="map">
|
||||
SELECT vr.id, r.name, r.cuisine_type,
|
||||
TO_CHAR(vr.foods_mentioned) AS foods_mentioned,
|
||||
v.title
|
||||
FROM video_restaurants vr
|
||||
JOIN restaurants r ON r.id = vr.restaurant_id
|
||||
JOIN videos v ON v.id = vr.video_id
|
||||
ORDER BY r.name
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
130
backend-java/src/main/resources/mybatis/mapper/ReviewMapper.xml
Normal file
130
backend-java/src/main/resources/mybatis/mapper/ReviewMapper.xml
Normal file
@@ -0,0 +1,130 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.tasteby.mapper.ReviewMapper">
|
||||
|
||||
<resultMap id="reviewResultMap" type="com.tasteby.domain.Review">
|
||||
<id property="id" column="id"/>
|
||||
<result property="userId" column="user_id"/>
|
||||
<result property="restaurantId" column="restaurant_id"/>
|
||||
<result property="rating" column="rating"/>
|
||||
<result property="reviewText" column="review_text" typeHandler="com.tasteby.config.ClobTypeHandler"/>
|
||||
<result property="visitedAt" column="visited_at"/>
|
||||
<result property="createdAt" column="created_at"/>
|
||||
<result property="updatedAt" column="updated_at"/>
|
||||
<result property="userNickname" column="nickname"/>
|
||||
<result property="userAvatarUrl" column="avatar_url"/>
|
||||
<result property="restaurantName" column="restaurant_name"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="restaurantResultMap" type="com.tasteby.domain.Restaurant">
|
||||
<id property="id" column="id"/>
|
||||
<result property="name" column="name"/>
|
||||
<result property="address" column="address"/>
|
||||
<result property="region" column="region"/>
|
||||
<result property="latitude" column="latitude"/>
|
||||
<result property="longitude" column="longitude"/>
|
||||
<result property="cuisineType" column="cuisine_type"/>
|
||||
<result property="priceRange" column="price_range"/>
|
||||
<result property="googlePlaceId" column="google_place_id"/>
|
||||
<result property="businessStatus" column="business_status"/>
|
||||
<result property="rating" column="rating"/>
|
||||
<result property="ratingCount" column="rating_count"/>
|
||||
<result property="updatedAt" column="created_at"/>
|
||||
</resultMap>
|
||||
|
||||
<insert id="insertReview">
|
||||
INSERT INTO user_reviews (id, user_id, restaurant_id, rating, review_text, visited_at)
|
||||
VALUES (#{id}, #{userId}, #{restaurantId}, #{rating}, #{reviewText},
|
||||
<choose>
|
||||
<when test="visitedAt != null">TO_DATE(#{visitedAt}, 'YYYY-MM-DD')</when>
|
||||
<otherwise>NULL</otherwise>
|
||||
</choose>)
|
||||
</insert>
|
||||
|
||||
<update id="updateReview">
|
||||
UPDATE user_reviews SET
|
||||
rating = COALESCE(#{rating}, rating),
|
||||
review_text = COALESCE(#{reviewText}, review_text),
|
||||
visited_at = COALESCE(
|
||||
<choose>
|
||||
<when test="visitedAt != null">TO_DATE(#{visitedAt}, 'YYYY-MM-DD')</when>
|
||||
<otherwise>NULL</otherwise>
|
||||
</choose>, visited_at),
|
||||
updated_at = SYSTIMESTAMP
|
||||
WHERE id = #{id} AND user_id = #{userId}
|
||||
</update>
|
||||
|
||||
<delete id="deleteReview">
|
||||
DELETE FROM user_reviews WHERE id = #{id} AND user_id = #{userId}
|
||||
</delete>
|
||||
|
||||
<select id="findById" resultMap="reviewResultMap">
|
||||
SELECT r.id, r.user_id, r.restaurant_id, r.rating, r.review_text,
|
||||
r.visited_at, r.created_at, r.updated_at,
|
||||
u.nickname, u.avatar_url
|
||||
FROM user_reviews r
|
||||
JOIN tasteby_users u ON u.id = r.user_id
|
||||
WHERE r.id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="findByRestaurant" resultMap="reviewResultMap">
|
||||
SELECT r.id, r.user_id, r.restaurant_id, r.rating, r.review_text,
|
||||
r.visited_at, r.created_at, r.updated_at,
|
||||
u.nickname, u.avatar_url
|
||||
FROM user_reviews r
|
||||
JOIN tasteby_users u ON u.id = r.user_id
|
||||
WHERE r.restaurant_id = #{restaurantId}
|
||||
ORDER BY r.created_at DESC
|
||||
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
|
||||
</select>
|
||||
|
||||
<select id="getAvgRating" resultType="map">
|
||||
SELECT ROUND(AVG(rating), 1) AS avg_rating, COUNT(*) AS review_count
|
||||
FROM user_reviews
|
||||
WHERE restaurant_id = #{restaurantId}
|
||||
</select>
|
||||
|
||||
<select id="findByUser" resultMap="reviewResultMap">
|
||||
SELECT r.id, r.user_id, r.restaurant_id, r.rating, r.review_text,
|
||||
r.visited_at, r.created_at, r.updated_at,
|
||||
u.nickname, u.avatar_url,
|
||||
rest.name AS restaurant_name
|
||||
FROM user_reviews r
|
||||
JOIN tasteby_users u ON u.id = r.user_id
|
||||
LEFT JOIN restaurants rest ON rest.id = r.restaurant_id
|
||||
WHERE r.user_id = #{userId}
|
||||
ORDER BY r.created_at DESC
|
||||
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
|
||||
</select>
|
||||
|
||||
<select id="countFavorite" resultType="int">
|
||||
SELECT COUNT(*) FROM user_favorites
|
||||
WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
|
||||
</select>
|
||||
|
||||
<insert id="insertFavorite">
|
||||
INSERT INTO user_favorites (id, user_id, restaurant_id)
|
||||
VALUES (#{id}, #{userId}, #{restaurantId})
|
||||
</insert>
|
||||
|
||||
<delete id="deleteFavorite">
|
||||
DELETE FROM user_favorites
|
||||
WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
|
||||
</delete>
|
||||
|
||||
<select id="findFavoriteId" resultType="string">
|
||||
SELECT id FROM user_favorites
|
||||
WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
|
||||
</select>
|
||||
|
||||
<select id="getUserFavorites" resultMap="restaurantResultMap">
|
||||
SELECT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
||||
r.cuisine_type, r.price_range, r.google_place_id,
|
||||
r.business_status, r.rating, r.rating_count, f.created_at
|
||||
FROM user_favorites f
|
||||
JOIN restaurants r ON r.id = f.restaurant_id
|
||||
WHERE f.user_id = #{userId}
|
||||
ORDER BY f.created_at DESC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.tasteby.mapper.SearchMapper">
|
||||
|
||||
<resultMap id="restaurantMap" type="Restaurant">
|
||||
<id property="id" column="id"/>
|
||||
<result property="name" column="name"/>
|
||||
<result property="address" column="address"/>
|
||||
<result property="region" column="region"/>
|
||||
<result property="latitude" column="latitude"/>
|
||||
<result property="longitude" column="longitude"/>
|
||||
<result property="cuisineType" column="cuisine_type"/>
|
||||
<result property="priceRange" column="price_range"/>
|
||||
<result property="phone" column="phone"/>
|
||||
<result property="website" column="website"/>
|
||||
<result property="googlePlaceId" column="google_place_id"/>
|
||||
<result property="tablingUrl" column="tabling_url"/>
|
||||
<result property="catchtableUrl" column="catchtable_url"/>
|
||||
<result property="businessStatus" column="business_status"/>
|
||||
<result property="rating" column="rating"/>
|
||||
<result property="ratingCount" column="rating_count"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="keywordSearch" resultMap="restaurantMap">
|
||||
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
||||
r.cuisine_type, r.price_range, r.phone, r.website, r.google_place_id,
|
||||
r.tabling_url, r.catchtable_url,
|
||||
r.business_status, r.rating, r.rating_count
|
||||
FROM restaurants r
|
||||
JOIN video_restaurants vr ON vr.restaurant_id = r.id
|
||||
JOIN videos v ON v.id = vr.video_id
|
||||
WHERE r.latitude IS NOT NULL
|
||||
AND (UPPER(r.name) LIKE UPPER(#{query})
|
||||
OR UPPER(r.address) LIKE UPPER(#{query})
|
||||
OR UPPER(r.region) LIKE UPPER(#{query})
|
||||
OR UPPER(r.cuisine_type) LIKE UPPER(#{query})
|
||||
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query})
|
||||
OR UPPER(v.title) LIKE UPPER(#{query}))
|
||||
FETCH FIRST #{limit} ROWS ONLY
|
||||
</select>
|
||||
|
||||
<select id="findChannelsByRestaurantIds" resultType="map">
|
||||
SELECT DISTINCT vr.restaurant_id, c.channel_name
|
||||
FROM video_restaurants vr
|
||||
JOIN videos v ON v.id = vr.video_id
|
||||
JOIN channels c ON c.id = v.channel_id
|
||||
WHERE vr.restaurant_id IN
|
||||
<foreach item="id" collection="ids" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.tasteby.mapper.StatsMapper">
|
||||
|
||||
<update id="recordVisit">
|
||||
MERGE INTO site_visits sv
|
||||
USING (SELECT TRUNC(SYSDATE) AS d FROM dual) src
|
||||
ON (sv.visit_date = src.d)
|
||||
WHEN MATCHED THEN UPDATE SET sv.visit_count = sv.visit_count + 1
|
||||
WHEN NOT MATCHED THEN INSERT (visit_date, visit_count) VALUES (src.d, 1)
|
||||
</update>
|
||||
|
||||
<select id="getTodayVisits" resultType="int">
|
||||
SELECT NVL(visit_count, 0)
|
||||
FROM site_visits
|
||||
WHERE visit_date = TRUNC(SYSDATE)
|
||||
</select>
|
||||
|
||||
<select id="getTotalVisits" resultType="int">
|
||||
SELECT NVL(SUM(visit_count), 0)
|
||||
FROM site_visits
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.tasteby.mapper.UserMapper">
|
||||
|
||||
<resultMap id="userResultMap" type="com.tasteby.domain.UserInfo">
|
||||
<id property="id" column="id"/>
|
||||
<result property="email" column="email"/>
|
||||
<result property="nickname" column="nickname"/>
|
||||
<result property="avatarUrl" column="avatar_url"/>
|
||||
<result property="admin" column="is_admin" javaType="boolean"/>
|
||||
<result property="provider" column="provider"/>
|
||||
<result property="createdAt" column="created_at"/>
|
||||
<result property="favoriteCount" column="favorite_count"/>
|
||||
<result property="reviewCount" column="review_count"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="findByProviderAndProviderId" resultMap="userResultMap">
|
||||
SELECT id, email, nickname, avatar_url, is_admin, provider, created_at
|
||||
FROM tasteby_users
|
||||
WHERE provider = #{provider} AND provider_id = #{providerId}
|
||||
</select>
|
||||
|
||||
<update id="updateLastLogin">
|
||||
UPDATE tasteby_users SET last_login_at = SYSTIMESTAMP WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
<insert id="insert">
|
||||
INSERT INTO tasteby_users (id, provider, provider_id, email, nickname, avatar_url)
|
||||
VALUES (#{id}, #{provider}, #{providerId}, #{email}, #{nickname}, #{avatarUrl})
|
||||
</insert>
|
||||
|
||||
<select id="findById" resultMap="userResultMap">
|
||||
SELECT id, email, nickname, avatar_url, is_admin, provider, created_at
|
||||
FROM tasteby_users
|
||||
WHERE id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="findAllWithCounts" resultMap="userResultMap">
|
||||
SELECT u.id, u.email, u.nickname, u.avatar_url, u.provider, u.created_at,
|
||||
NVL(fav.cnt, 0) AS favorite_count,
|
||||
NVL(rev.cnt, 0) AS review_count
|
||||
FROM tasteby_users u
|
||||
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_favorites GROUP BY user_id) fav ON fav.user_id = u.id
|
||||
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_reviews GROUP BY user_id) rev ON rev.user_id = u.id
|
||||
ORDER BY u.created_at DESC
|
||||
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
|
||||
</select>
|
||||
|
||||
<select id="countAll" resultType="int">
|
||||
SELECT COUNT(*) FROM tasteby_users
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.tasteby.mapper.VectorMapper">
|
||||
|
||||
<resultMap id="vectorSearchResultMap" type="VectorSearchResult">
|
||||
<result property="restaurantId" column="restaurant_id"/>
|
||||
<result property="chunkText" column="chunk_text"
|
||||
typeHandler="com.tasteby.config.ClobTypeHandler"/>
|
||||
<result property="distance" column="dist"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="searchSimilar" resultMap="vectorSearchResultMap">
|
||||
<![CDATA[
|
||||
SELECT rv.restaurant_id, rv.chunk_text,
|
||||
VECTOR_DISTANCE(rv.embedding, TO_VECTOR(#{queryVec}), COSINE) AS dist
|
||||
FROM restaurant_vectors rv
|
||||
WHERE VECTOR_DISTANCE(rv.embedding, TO_VECTOR(#{queryVec}), COSINE) <= #{maxDistance}
|
||||
ORDER BY dist
|
||||
FETCH FIRST #{topK} ROWS ONLY
|
||||
]]>
|
||||
</select>
|
||||
|
||||
<insert id="insertVector">
|
||||
INSERT INTO restaurant_vectors (id, restaurant_id, chunk_text, embedding)
|
||||
VALUES (#{id}, #{restaurantId}, #{chunkText}, TO_VECTOR(#{embedding}))
|
||||
</insert>
|
||||
|
||||
</mapper>
|
||||
256
backend-java/src/main/resources/mybatis/mapper/VideoMapper.xml
Normal file
256
backend-java/src/main/resources/mybatis/mapper/VideoMapper.xml
Normal file
@@ -0,0 +1,256 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.tasteby.mapper.VideoMapper">
|
||||
|
||||
<!-- ===== Result Maps ===== -->
|
||||
|
||||
<resultMap id="videoSummaryMap" type="VideoSummary">
|
||||
<id property="id" column="id"/>
|
||||
<result property="videoId" column="video_id"/>
|
||||
<result property="title" column="title"/>
|
||||
<result property="url" column="url"/>
|
||||
<result property="status" column="status"/>
|
||||
<result property="publishedAt" column="published_at"/>
|
||||
<result property="channelName" column="channel_name"/>
|
||||
<result property="hasTranscript" column="has_transcript"/>
|
||||
<result property="hasLlm" column="has_llm"/>
|
||||
<result property="restaurantCount" column="restaurant_count"/>
|
||||
<result property="matchedCount" column="matched_count"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="videoDetailMap" type="VideoDetail">
|
||||
<id property="id" column="id"/>
|
||||
<result property="videoId" column="video_id"/>
|
||||
<result property="title" column="title"/>
|
||||
<result property="url" column="url"/>
|
||||
<result property="status" column="status"/>
|
||||
<result property="publishedAt" column="published_at"/>
|
||||
<result property="channelName" column="channel_name"/>
|
||||
<result property="transcriptText" column="transcript_text"
|
||||
typeHandler="com.tasteby.config.ClobTypeHandler"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="videoRestaurantLinkMap" type="VideoRestaurantLink">
|
||||
<result property="restaurantId" column="id"/>
|
||||
<result property="name" column="name"/>
|
||||
<result property="address" column="address"/>
|
||||
<result property="cuisineType" column="cuisine_type"/>
|
||||
<result property="priceRange" column="price_range"/>
|
||||
<result property="region" column="region"/>
|
||||
<result property="foodsMentioned" column="foods_mentioned"
|
||||
typeHandler="com.tasteby.config.ClobTypeHandler"/>
|
||||
<result property="evaluation" column="evaluation"
|
||||
typeHandler="com.tasteby.config.ClobTypeHandler"/>
|
||||
<result property="guests" column="guests"
|
||||
typeHandler="com.tasteby.config.ClobTypeHandler"/>
|
||||
<result property="googlePlaceId" column="google_place_id"/>
|
||||
<result property="latitude" column="latitude"/>
|
||||
<result property="longitude" column="longitude"/>
|
||||
</resultMap>
|
||||
|
||||
<!-- ===== Queries ===== -->
|
||||
|
||||
<select id="findAll" resultMap="videoSummaryMap">
|
||||
SELECT v.id, v.video_id, v.title, v.url, v.status,
|
||||
v.published_at, c.channel_name,
|
||||
CASE WHEN v.transcript_text IS NOT NULL AND dbms_lob.getlength(v.transcript_text) > 0 THEN 1 ELSE 0 END AS has_transcript,
|
||||
CASE WHEN v.llm_raw_response IS NOT NULL AND dbms_lob.getlength(v.llm_raw_response) > 0 THEN 1 ELSE 0 END AS has_llm,
|
||||
(SELECT COUNT(*) FROM video_restaurants vr WHERE vr.video_id = v.id) AS restaurant_count,
|
||||
(SELECT COUNT(*) FROM video_restaurants vr JOIN restaurants r ON r.id = vr.restaurant_id
|
||||
WHERE vr.video_id = v.id AND r.google_place_id IS NOT NULL) AS matched_count
|
||||
FROM videos v
|
||||
JOIN channels c ON c.id = v.channel_id
|
||||
<if test="status != null and status != ''">
|
||||
WHERE v.status = #{status}
|
||||
</if>
|
||||
ORDER BY v.published_at DESC NULLS LAST
|
||||
</select>
|
||||
|
||||
<select id="findDetail" resultMap="videoDetailMap">
|
||||
SELECT v.id, v.video_id, v.title, v.url, v.status,
|
||||
v.published_at, v.transcript_text, c.channel_name
|
||||
FROM videos v
|
||||
JOIN channels c ON c.id = v.channel_id
|
||||
WHERE v.id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="findVideoRestaurants" resultMap="videoRestaurantLinkMap">
|
||||
SELECT r.id, r.name, r.address, r.cuisine_type, r.price_range, r.region,
|
||||
vr.foods_mentioned, vr.evaluation, vr.guests,
|
||||
r.google_place_id, r.latitude, r.longitude
|
||||
FROM video_restaurants vr
|
||||
JOIN restaurants r ON r.id = vr.restaurant_id
|
||||
WHERE vr.video_id = #{videoId}
|
||||
</select>
|
||||
|
||||
<!-- ===== Updates ===== -->
|
||||
|
||||
<update id="updateStatus">
|
||||
UPDATE videos SET status = #{status} WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
<update id="updateTitle">
|
||||
UPDATE videos SET title = #{title} WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
<update id="updateTranscript">
|
||||
UPDATE videos SET transcript_text = #{transcript} WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
<update id="updateVideoFields">
|
||||
UPDATE videos SET
|
||||
status = #{status},
|
||||
processed_at = SYSTIMESTAMP
|
||||
<if test="transcript != null">
|
||||
, transcript_text = #{transcript}
|
||||
</if>
|
||||
<if test="llmResponse != null">
|
||||
, llm_raw_response = #{llmResponse}
|
||||
</if>
|
||||
WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
<!-- ===== Cascade deletes for video deletion ===== -->
|
||||
|
||||
<delete id="deleteVectorsByVideoOnly">
|
||||
DELETE FROM restaurant_vectors WHERE restaurant_id IN (
|
||||
SELECT vr.restaurant_id FROM video_restaurants vr
|
||||
WHERE vr.video_id = #{videoId}
|
||||
AND NOT EXISTS (SELECT 1 FROM video_restaurants vr2
|
||||
WHERE vr2.restaurant_id = vr.restaurant_id AND vr2.video_id != #{videoId})
|
||||
)
|
||||
</delete>
|
||||
|
||||
<delete id="deleteReviewsByVideoOnly">
|
||||
DELETE FROM user_reviews WHERE restaurant_id IN (
|
||||
SELECT vr.restaurant_id FROM video_restaurants vr
|
||||
WHERE vr.video_id = #{videoId}
|
||||
AND NOT EXISTS (SELECT 1 FROM video_restaurants vr2
|
||||
WHERE vr2.restaurant_id = vr.restaurant_id AND vr2.video_id != #{videoId})
|
||||
)
|
||||
</delete>
|
||||
|
||||
<delete id="deleteFavoritesByVideoOnly">
|
||||
DELETE FROM user_favorites WHERE restaurant_id IN (
|
||||
SELECT vr.restaurant_id FROM video_restaurants vr
|
||||
WHERE vr.video_id = #{videoId}
|
||||
AND NOT EXISTS (SELECT 1 FROM video_restaurants vr2
|
||||
WHERE vr2.restaurant_id = vr.restaurant_id AND vr2.video_id != #{videoId})
|
||||
)
|
||||
</delete>
|
||||
|
||||
<delete id="deleteRestaurantsByVideoOnly">
|
||||
DELETE FROM restaurants WHERE id IN (
|
||||
SELECT vr.restaurant_id FROM video_restaurants vr
|
||||
WHERE vr.video_id = #{videoId}
|
||||
AND NOT EXISTS (SELECT 1 FROM video_restaurants vr2
|
||||
WHERE vr2.restaurant_id = vr.restaurant_id AND vr2.video_id != #{videoId})
|
||||
)
|
||||
</delete>
|
||||
|
||||
<delete id="deleteVideoRestaurants">
|
||||
DELETE FROM video_restaurants WHERE video_id = #{videoId}
|
||||
</delete>
|
||||
|
||||
<delete id="deleteVideo">
|
||||
DELETE FROM videos WHERE id = #{videoId}
|
||||
</delete>
|
||||
|
||||
<!-- ===== Single video-restaurant unlink + orphan cleanup ===== -->
|
||||
|
||||
<delete id="deleteOneVideoRestaurant">
|
||||
DELETE FROM video_restaurants WHERE video_id = #{videoId} AND restaurant_id = #{restaurantId}
|
||||
</delete>
|
||||
|
||||
<delete id="cleanupOrphanVectors">
|
||||
DELETE FROM restaurant_vectors WHERE restaurant_id = #{restaurantId}
|
||||
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = #{restaurantId})
|
||||
</delete>
|
||||
|
||||
<delete id="cleanupOrphanReviews">
|
||||
DELETE FROM user_reviews WHERE restaurant_id = #{restaurantId}
|
||||
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = #{restaurantId})
|
||||
</delete>
|
||||
|
||||
<delete id="cleanupOrphanFavorites">
|
||||
DELETE FROM user_favorites WHERE restaurant_id = #{restaurantId}
|
||||
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = #{restaurantId})
|
||||
</delete>
|
||||
|
||||
<delete id="cleanupOrphanRestaurant">
|
||||
DELETE FROM restaurants WHERE id = #{restaurantId}
|
||||
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = #{restaurantId})
|
||||
</delete>
|
||||
|
||||
<!-- ===== Insert / Lookup ===== -->
|
||||
|
||||
<insert id="insertVideo">
|
||||
INSERT INTO videos (id, channel_id, video_id, title, url, published_at)
|
||||
VALUES (#{id}, #{channelId}, #{videoId}, #{title}, #{url},
|
||||
TO_TIMESTAMP(#{publishedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
|
||||
</insert>
|
||||
|
||||
<select id="getExistingVideoIds" resultType="string">
|
||||
SELECT video_id FROM videos WHERE channel_id = #{channelId}
|
||||
</select>
|
||||
|
||||
<select id="getLatestVideoDate" resultType="string">
|
||||
SELECT TO_CHAR(MAX(published_at), 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS latest_date
|
||||
FROM videos WHERE channel_id = #{channelId}
|
||||
</select>
|
||||
|
||||
<!-- ===== Pipeline queries ===== -->
|
||||
|
||||
<select id="findPendingVideos" resultType="map">
|
||||
SELECT id, video_id, title, url FROM videos
|
||||
WHERE status = 'pending' ORDER BY created_at
|
||||
FETCH FIRST #{limit} ROWS ONLY
|
||||
</select>
|
||||
|
||||
<select id="findVideosForBulkExtract" resultType="map">
|
||||
SELECT v.id, v.video_id, v.title, v.url, v.transcript_text
|
||||
FROM videos v
|
||||
WHERE v.transcript_text IS NOT NULL
|
||||
AND dbms_lob.getlength(v.transcript_text) > 0
|
||||
AND (v.llm_raw_response IS NULL OR dbms_lob.getlength(v.llm_raw_response) = 0)
|
||||
AND v.status != 'skip'
|
||||
ORDER BY v.published_at DESC
|
||||
</select>
|
||||
|
||||
<select id="findVideosWithoutTranscript" resultType="map">
|
||||
SELECT id, video_id, title, url
|
||||
FROM videos
|
||||
WHERE (transcript_text IS NULL OR dbms_lob.getlength(transcript_text) = 0)
|
||||
AND status NOT IN ('skip', 'no_transcript')
|
||||
ORDER BY created_at
|
||||
</select>
|
||||
|
||||
<select id="findVideosByIds" resultType="map">
|
||||
SELECT id, video_id, title, url
|
||||
FROM videos
|
||||
WHERE id IN
|
||||
<foreach item="id" collection="ids" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
ORDER BY created_at
|
||||
</select>
|
||||
|
||||
<select id="findVideosForExtractByIds" resultType="map">
|
||||
SELECT v.id, v.video_id, v.title, v.url, v.transcript_text
|
||||
FROM videos v
|
||||
WHERE v.id IN
|
||||
<foreach item="id" collection="ids" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
ORDER BY v.published_at DESC
|
||||
</select>
|
||||
|
||||
<update id="updateVideoRestaurantFields">
|
||||
UPDATE video_restaurants
|
||||
SET foods_mentioned = #{foodsJson,jdbcType=CLOB},
|
||||
evaluation = #{evaluation,jdbcType=CLOB},
|
||||
guests = #{guestsJson,jdbcType=CLOB}
|
||||
WHERE video_id = #{videoId} AND restaurant_id = #{restaurantId}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
10
backend-java/src/main/resources/mybatis/mybatis-config.xml
Normal file
10
backend-java/src/main/resources/mybatis/mybatis-config.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
|
||||
<configuration>
|
||||
<settings>
|
||||
<setting name="mapUnderscoreToCamelCase" value="true"/>
|
||||
<setting name="callSettersOnNulls" value="true"/>
|
||||
<setting name="returnInstanceForEmptyRow" value="true"/>
|
||||
<setting name="jdbcTypeForNull" value="VARCHAR"/>
|
||||
</settings>
|
||||
</configuration>
|
||||
10
backend-java/start.sh
Executable file
10
backend-java/start.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
export JAVA_HOME="/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home"
|
||||
export PATH="/opt/homebrew/opt/openjdk@21/bin:$PATH"
|
||||
|
||||
# Load env from Python backend (shared env)
|
||||
set -a
|
||||
source /Users/joungmin/workspaces/tasteby/backend/.env
|
||||
set +a
|
||||
|
||||
exec ./gradlew bootRun
|
||||
@@ -307,6 +307,131 @@ def remap_cuisine(_admin: dict = Depends(get_admin_user)):
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.post("/remap-foods")
|
||||
def remap_foods(_admin: dict = Depends(get_admin_user)):
|
||||
"""Re-extract foods_mentioned for all video_restaurants using LLM. SSE progress."""
|
||||
from core.extractor import _llm, _parse_json
|
||||
from core.db import conn as db_conn
|
||||
|
||||
BATCH = 15
|
||||
|
||||
_FOODS_PROMPT = """\
|
||||
아래 식당들의 대표 메뉴 태그를 다시 만들어주세요.
|
||||
|
||||
규칙:
|
||||
- 반드시 한글로 작성
|
||||
- 각 식당당 최대 10개의 대표 메뉴/음식 태그
|
||||
- 우선순위: 시그니처 메뉴 > 자주 언급된 메뉴 > 일반 메뉴
|
||||
- 너무 일반적인 태그(밥, 반찬 등)는 제외
|
||||
- 모든 식당에 대해 빠짐없이 결과 반환 (총 {count}개)
|
||||
- JSON 배열만 반환, 설명 없음
|
||||
- 형식: [{{"id": "식당ID", "foods": ["메뉴1", "메뉴2", ...]}}]
|
||||
|
||||
식당 목록:
|
||||
{restaurants}
|
||||
|
||||
JSON 배열:"""
|
||||
|
||||
def _apply_batch(batch: list[dict]) -> tuple[int, list[dict]]:
|
||||
items = [{"id": b["id"], "name": b["name"], "current_foods": b["foods"], "cuisine_type": b.get("cuisine_type")} for b in batch]
|
||||
prompt = _FOODS_PROMPT.format(
|
||||
restaurants=_json.dumps(items, ensure_ascii=False),
|
||||
count=len(items),
|
||||
)
|
||||
raw = _llm(prompt, max_tokens=4096)
|
||||
results = _parse_json(raw)
|
||||
if not isinstance(results, list):
|
||||
return 0, batch
|
||||
|
||||
result_map: dict[str, list[str]] = {}
|
||||
for item in results:
|
||||
if isinstance(item, dict) and "id" in item and "foods" in item:
|
||||
foods = item["foods"]
|
||||
if isinstance(foods, list):
|
||||
# Ensure Korean, max 10
|
||||
foods = [str(f) for f in foods[:10]]
|
||||
result_map[item["id"]] = foods
|
||||
|
||||
updated = 0
|
||||
missed = []
|
||||
for b in batch:
|
||||
bid = b["id"]
|
||||
new_foods = result_map.get(bid)
|
||||
if new_foods is None:
|
||||
missed.append(b)
|
||||
continue
|
||||
with db_conn() as c:
|
||||
c.cursor().execute(
|
||||
"UPDATE video_restaurants SET foods_mentioned = :foods WHERE id = :id",
|
||||
{"foods": _json.dumps(new_foods, ensure_ascii=False), "id": bid},
|
||||
)
|
||||
updated += 1
|
||||
return updated, missed
|
||||
|
||||
def generate():
|
||||
# Fetch all video_restaurants with context
|
||||
sql = """
|
||||
SELECT vr.id, r.name, r.cuisine_type,
|
||||
vr.foods_mentioned, v.title
|
||||
FROM video_restaurants vr
|
||||
JOIN restaurants r ON r.id = vr.restaurant_id
|
||||
JOIN videos v ON v.id = vr.video_id
|
||||
ORDER BY r.name
|
||||
"""
|
||||
with db_conn() as c:
|
||||
cur = c.cursor()
|
||||
cur.execute(sql)
|
||||
rows = []
|
||||
for row in cur.fetchall():
|
||||
foods_raw = row[3].read() if hasattr(row[3], "read") else (row[3] or "[]")
|
||||
try:
|
||||
foods = _json.loads(foods_raw) if isinstance(foods_raw, str) else foods_raw
|
||||
except Exception:
|
||||
foods = []
|
||||
rows.append({
|
||||
"id": row[0],
|
||||
"name": row[1],
|
||||
"cuisine_type": row[2],
|
||||
"foods": foods if isinstance(foods, list) else [],
|
||||
"video_title": row[4],
|
||||
})
|
||||
|
||||
total = len(rows)
|
||||
yield f"data: {_json.dumps({'type': 'start', 'total': total})}\n\n"
|
||||
|
||||
updated = 0
|
||||
all_missed: list[dict] = []
|
||||
|
||||
for i in range(0, total, BATCH):
|
||||
batch = rows[i : i + BATCH]
|
||||
yield f"data: {_json.dumps({'type': 'processing', 'current': min(i + BATCH, total), 'total': total})}\n\n"
|
||||
try:
|
||||
cnt, missed = _apply_batch(batch)
|
||||
updated += cnt
|
||||
all_missed.extend(missed)
|
||||
yield f"data: {_json.dumps({'type': 'batch_done', 'current': min(i + BATCH, total), 'total': total, 'updated': updated})}\n\n"
|
||||
except Exception as e:
|
||||
logger.error("Remap foods error at %d: %s", i, e, exc_info=True)
|
||||
all_missed.extend(batch)
|
||||
yield f"data: {_json.dumps({'type': 'error', 'message': str(e), 'current': i})}\n\n"
|
||||
|
||||
# Retry missed
|
||||
if all_missed:
|
||||
yield f"data: {_json.dumps({'type': 'retry', 'missed': len(all_missed)})}\n\n"
|
||||
for i in range(0, len(all_missed), 10):
|
||||
batch = all_missed[i : i + 10]
|
||||
try:
|
||||
cnt, _ = _apply_batch(batch)
|
||||
updated += cnt
|
||||
except Exception as e:
|
||||
logger.error("Remap foods retry error: %s", e)
|
||||
|
||||
cache.flush()
|
||||
yield f"data: {_json.dumps({'type': 'complete', 'total': total, 'updated': updated})}\n\n"
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.post("/rebuild-vectors")
|
||||
def rebuild_vectors(_admin: dict = Depends(get_admin_user)):
|
||||
"""Rebuild all restaurant vector embeddings. Streams SSE progress."""
|
||||
|
||||
@@ -110,7 +110,7 @@ _EXTRACT_PROMPT = """\
|
||||
- cuisine_type: 아래 목록에서 가장 적합한 것을 선택 (string, 필수). 반드시 아래 목록 중 하나를 사용:
|
||||
{cuisine_types}
|
||||
- price_range: 가격대 (예: 1만원대, 2-3만원) (string | null)
|
||||
- foods_mentioned: 언급된 메뉴들 (string[])
|
||||
- foods_mentioned: 언급된 대표 메뉴 (string[], 최대 10개, 우선순위 높은 순, 반드시 한글로 작성)
|
||||
- evaluation: 평가 내용 (string | null)
|
||||
- guests: 함께한 게스트 (string[])
|
||||
|
||||
|
||||
@@ -314,8 +314,31 @@ def get_all(
|
||||
for row in cur.fetchall():
|
||||
ch_map.setdefault(row[0], []).append(row[1])
|
||||
|
||||
# Attach aggregated foods_mentioned for each restaurant
|
||||
foods_sql = f"""
|
||||
SELECT vr.restaurant_id, vr.foods_mentioned
|
||||
FROM video_restaurants vr
|
||||
WHERE vr.restaurant_id IN ({placeholders})
|
||||
"""
|
||||
foods_map: dict[str, list[str]] = {}
|
||||
with conn() as c:
|
||||
cur = c.cursor()
|
||||
cur.execute(foods_sql, ch_params)
|
||||
for row in cur.fetchall():
|
||||
raw = row[1].read() if hasattr(row[1], "read") else row[1]
|
||||
if raw:
|
||||
try:
|
||||
items = json.loads(raw) if isinstance(raw, str) else raw
|
||||
if isinstance(items, list):
|
||||
for f in items:
|
||||
if isinstance(f, str) and f not in foods_map.get(row[0], []):
|
||||
foods_map.setdefault(row[0], []).append(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for r in restaurants:
|
||||
r["channels"] = ch_map.get(r["id"], [])
|
||||
r["foods_mentioned"] = foods_map.get(r["id"], [])[:10]
|
||||
|
||||
return restaurants
|
||||
|
||||
|
||||
61
build_spec.yaml
Normal file
61
build_spec.yaml
Normal file
@@ -0,0 +1,61 @@
|
||||
version: 0.1
|
||||
component: build
|
||||
timeoutInSeconds: 1800
|
||||
runAs: root
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
variables:
|
||||
REGISTRY: "icn.ocir.io/idyhsdamac8c/tasteby"
|
||||
exportedVariables:
|
||||
- IMAGE_TAG
|
||||
- BACKEND_IMAGE
|
||||
- FRONTEND_IMAGE
|
||||
|
||||
steps:
|
||||
- type: Command
|
||||
name: "Setup buildx for ARM64"
|
||||
command: |
|
||||
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
||||
docker buildx create --name armbuilder --use
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- type: Command
|
||||
name: "Set image tag"
|
||||
command: |
|
||||
IMAGE_TAG="${OCI_BUILD_RUN_ID:0:8}-$(date +%Y%m%d%H%M)"
|
||||
BACKEND_IMAGE="${REGISTRY}/backend:${IMAGE_TAG}"
|
||||
FRONTEND_IMAGE="${REGISTRY}/frontend:${IMAGE_TAG}"
|
||||
echo "IMAGE_TAG=${IMAGE_TAG}"
|
||||
echo "BACKEND_IMAGE=${BACKEND_IMAGE}"
|
||||
echo "FRONTEND_IMAGE=${FRONTEND_IMAGE}"
|
||||
|
||||
- type: Command
|
||||
name: "Build backend image"
|
||||
command: |
|
||||
cd backend-java
|
||||
docker buildx build --platform linux/arm64 \
|
||||
-t "${BACKEND_IMAGE}" \
|
||||
-t "${REGISTRY}/backend:latest" \
|
||||
--load \
|
||||
.
|
||||
|
||||
- type: Command
|
||||
name: "Build frontend image"
|
||||
command: |
|
||||
cd frontend
|
||||
docker buildx build --platform linux/arm64 \
|
||||
--build-arg NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="${NEXT_PUBLIC_GOOGLE_MAPS_API_KEY}" \
|
||||
--build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID="${NEXT_PUBLIC_GOOGLE_CLIENT_ID}" \
|
||||
-t "${FRONTEND_IMAGE}" \
|
||||
-t "${REGISTRY}/frontend:latest" \
|
||||
--load \
|
||||
.
|
||||
|
||||
outputArtifacts:
|
||||
- name: backend-image
|
||||
type: DOCKER_IMAGE
|
||||
location: ${BACKEND_IMAGE}
|
||||
- name: frontend-image
|
||||
type: DOCKER_IMAGE
|
||||
location: ${FRONTEND_IMAGE}
|
||||
129
deploy.sh
Executable file
129
deploy.sh
Executable file
@@ -0,0 +1,129 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# ── Configuration ──
|
||||
REGISTRY="icn.ocir.io/idyhsdamac8c/tasteby"
|
||||
NAMESPACE="tasteby"
|
||||
PLATFORM="linux/arm64"
|
||||
|
||||
# ── Parse arguments ──
|
||||
TARGET="all" # all | backend | frontend
|
||||
MESSAGE=""
|
||||
DRY_RUN=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--backend-only) TARGET="backend"; shift ;;
|
||||
--frontend-only) TARGET="frontend"; shift ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
-m) MESSAGE="$2"; shift 2 ;;
|
||||
*) MESSAGE="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Determine next version ──
|
||||
LATEST_TAG=$(git tag --sort=-v:refname | grep '^v' | head -1 2>/dev/null || echo "v0.1.0")
|
||||
MAJOR=$(echo "$LATEST_TAG" | cut -d. -f1)
|
||||
MINOR=$(echo "$LATEST_TAG" | cut -d. -f2)
|
||||
PATCH=$(echo "$LATEST_TAG" | cut -d. -f3)
|
||||
NEXT_PATCH=$((PATCH + 1))
|
||||
TAG="${MAJOR}.${MINOR}.${NEXT_PATCH}"
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " Deploying Tasteby ${TAG}"
|
||||
echo " Target: ${TARGET}"
|
||||
echo " Message: ${MESSAGE:-<none>}"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
if $DRY_RUN; then
|
||||
echo "[DRY RUN] Would build & push images, apply K8s manifests, create git tag."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
# ── Build & Push ──
|
||||
if [[ "$TARGET" == "all" || "$TARGET" == "backend" ]]; then
|
||||
echo ""
|
||||
echo "▶ Building backend image..."
|
||||
docker build --platform "$PLATFORM" \
|
||||
-t "$REGISTRY/backend:$TAG" \
|
||||
-t "$REGISTRY/backend:latest" \
|
||||
backend-java/
|
||||
echo "▶ Pushing backend image..."
|
||||
docker push "$REGISTRY/backend:$TAG"
|
||||
docker push "$REGISTRY/backend:latest"
|
||||
fi
|
||||
|
||||
if [[ "$TARGET" == "all" || "$TARGET" == "frontend" ]]; then
|
||||
echo ""
|
||||
echo "▶ Building frontend image..."
|
||||
|
||||
# Read build args from env or .env file
|
||||
MAPS_KEY="${NEXT_PUBLIC_GOOGLE_MAPS_API_KEY:-}"
|
||||
CLIENT_ID="${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}"
|
||||
|
||||
if [[ -f frontend/.env.local ]]; then
|
||||
MAPS_KEY="${MAPS_KEY:-$(grep NEXT_PUBLIC_GOOGLE_MAPS_API_KEY frontend/.env.local 2>/dev/null | cut -d= -f2)}"
|
||||
CLIENT_ID="${CLIENT_ID:-$(grep NEXT_PUBLIC_GOOGLE_CLIENT_ID frontend/.env.local 2>/dev/null | cut -d= -f2)}"
|
||||
fi
|
||||
|
||||
docker build --platform "$PLATFORM" \
|
||||
--build-arg NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="$MAPS_KEY" \
|
||||
--build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID="$CLIENT_ID" \
|
||||
-t "$REGISTRY/frontend:$TAG" \
|
||||
-t "$REGISTRY/frontend:latest" \
|
||||
frontend/
|
||||
echo "▶ Pushing frontend image..."
|
||||
docker push "$REGISTRY/frontend:$TAG"
|
||||
docker push "$REGISTRY/frontend:latest"
|
||||
fi
|
||||
|
||||
# ── Deploy to K8s ──
|
||||
echo ""
|
||||
echo "▶ Updating K8s deployments..."
|
||||
|
||||
if [[ "$TARGET" == "all" || "$TARGET" == "backend" ]]; then
|
||||
kubectl set image deployment/backend \
|
||||
backend="$REGISTRY/backend:$TAG" \
|
||||
-n "$NAMESPACE"
|
||||
echo " Waiting for backend rollout..."
|
||||
kubectl rollout status deployment/backend -n "$NAMESPACE" --timeout=180s
|
||||
fi
|
||||
|
||||
if [[ "$TARGET" == "all" || "$TARGET" == "frontend" ]]; then
|
||||
kubectl set image deployment/frontend \
|
||||
frontend="$REGISTRY/frontend:$TAG" \
|
||||
-n "$NAMESPACE"
|
||||
echo " Waiting for frontend rollout..."
|
||||
kubectl rollout status deployment/frontend -n "$NAMESPACE" --timeout=120s
|
||||
fi
|
||||
|
||||
# ── Git tag ──
|
||||
echo ""
|
||||
echo "▶ Creating git tag ${TAG}..."
|
||||
|
||||
TAG_MESSAGE="Deploy ${TAG}"
|
||||
if [[ -n "$MESSAGE" ]]; then
|
||||
TAG_MESSAGE="${TAG_MESSAGE}: ${MESSAGE}"
|
||||
fi
|
||||
|
||||
# Include changed components
|
||||
COMPONENTS=""
|
||||
[[ "$TARGET" == "all" || "$TARGET" == "backend" ]] && COMPONENTS="backend"
|
||||
[[ "$TARGET" == "all" || "$TARGET" == "frontend" ]] && COMPONENTS="${COMPONENTS:+$COMPONENTS, }frontend"
|
||||
TAG_MESSAGE="${TAG_MESSAGE}
|
||||
|
||||
Components: ${COMPONENTS}
|
||||
Images:
|
||||
$([ "$TARGET" == "all" ] || [ "$TARGET" == "backend" ] && echo " - ${REGISTRY}/backend:${TAG}")
|
||||
$([ "$TARGET" == "all" ] || [ "$TARGET" == "frontend" ] && echo " - ${REGISTRY}/frontend:${TAG}")"
|
||||
|
||||
git tag -a "$TAG" -m "$TAG_MESSAGE"
|
||||
git push origin "$TAG"
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " ✅ Deploy complete: ${TAG}"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
kubectl get pods -n "$NAMESPACE"
|
||||
405
docs/cicd-architecture.md
Normal file
405
docs/cicd-architecture.md
Normal file
@@ -0,0 +1,405 @@
|
||||
# Tasteby CI/CD 파이프라인 & 전체 아키텍처
|
||||
|
||||
## 전체 시스템 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Internet │
|
||||
│ www.tasteby.net │
|
||||
└──────────────────────────────┬──────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ Namecheap DNS │
|
||||
│ A → LB External IP │
|
||||
└─────────┬──────────┘
|
||||
│
|
||||
┌──────────────────────────────▼──────────────────────────────────────────┐
|
||||
│ OCI Load Balancer (NLB) │
|
||||
│ (Nginx Ingress Controller가 자동 생성) │
|
||||
└──────────────────────────────┬──────────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────────────────▼──────────────────────────────────────────┐
|
||||
│ OKE Cluster (tasteby-cluster) │
|
||||
│ ap-seoul-1 │ ARM64 × 2 노드 (2CPU/8GB 각) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Nginx Ingress Controller (ingress-nginx) │ │
|
||||
│ │ │ │
|
||||
│ │ TLS termination (Let's Encrypt via cert-manager) │ │
|
||||
│ │ tasteby.net → www.tasteby.net 리다이렉트 │ │
|
||||
│ │ │ │
|
||||
│ │ /api/* ──→ backend Service :8000 │ │
|
||||
│ │ /* ──→ frontend Service :3001 │ │
|
||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─── namespace: tasteby ─────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────────┐ ┌──────────────────┐ ┌────────────────┐ │ │
|
||||
│ │ │ backend (×1) │ │ frontend (×1) │ │ redis (×1) │ │ │
|
||||
│ │ │ Spring Boot 3 │ │ Next.js 15 │ │ Redis 7 │ │ │
|
||||
│ │ │ Java 21 │ │ standalone mode │ │ alpine │ │ │
|
||||
│ │ │ :8000 │ │ :3001 │ │ :6379 │ │ │
|
||||
│ │ │ │ │ │ │ │ │ │
|
||||
│ │ │ ┌─ Volumes ─────┐│ └──────────────────┘ └────────────────┘ │ │
|
||||
│ │ │ │ oracle-wallet ││ │ │
|
||||
│ │ │ │ oci-config ││ │ │
|
||||
│ │ │ └───────────────┘│ │ │
|
||||
│ │ └──────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─── ConfigMap / Secrets ──────────────────────────────────────┐ │ │
|
||||
│ │ │ tasteby-config : REDIS_HOST, OCI endpoints, 등 │ │ │
|
||||
│ │ │ tasteby-secrets : DB credentials, API keys, JWT │ │ │
|
||||
│ │ │ oracle-wallet : cwallet.sso, tnsnames.ora, keystore.jks │ │ │
|
||||
│ │ │ oci-config : OCI API config + PEM key │ │ │
|
||||
│ │ │ ocir-secret : OCIR 이미지 Pull 인증 │ │ │
|
||||
│ │ └──────────────────────────────────────────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─── namespace: cert-manager ──┐ ┌─── namespace: ingress-nginx ──┐ │
|
||||
│ │ cert-manager (×3 pods) │ │ ingress-nginx-controller │ │
|
||||
│ │ ClusterIssuer: letsencrypt │ │ (NLB 자동 생성) │ │
|
||||
│ └──────────────────────────────┘ └────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ Oracle ADB 23ai │
|
||||
│ (mTLS via Wallet) │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
## 외부 서비스 연동
|
||||
|
||||
```
|
||||
backend (Spring Boot)
|
||||
├─→ Oracle ADB 23ai : JDBC + mTLS (Wallet)
|
||||
├─→ OCI GenAI (Chat) : 식당 정보 추출, 음식 태그, 평가 생성
|
||||
├─→ OCI GenAI (Embed) : 벡터 임베딩 (Cohere embed-v4)
|
||||
├─→ Google Maps Places API : 식당 검색, 좌표 조회
|
||||
├─→ YouTube Data API v3 : 채널/영상 정보 조회
|
||||
└─→ Redis : 캐시 (API 응답, 검색 결과)
|
||||
|
||||
frontend (Next.js)
|
||||
├─→ Google Maps JavaScript API : 지도 렌더링
|
||||
└─→ Google OAuth 2.0 : 사용자 인증
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD 파이프라인
|
||||
|
||||
### 파이프라인 개요
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────────────┐ ┌──────────────────────┐ ┌─────────┐
|
||||
│ 개발자 │────→│ OCI Code Repo │────→│ OCI DevOps Build │────→│ OKE │
|
||||
│ git push │ │ (tasteby) │ │ Pipeline │ │ 배포 │
|
||||
└──────────┘ └──────────────────┘ └──────────────────────┘ └─────────┘
|
||||
```
|
||||
|
||||
### 상세 흐름
|
||||
|
||||
```
|
||||
1. 코드 푸시
|
||||
┌──────────────┐
|
||||
│ git push oci │ ← OCI Code Repository remote
|
||||
└──────┬───────┘
|
||||
│
|
||||
▼
|
||||
2. 빌드 파이프라인 (OCI DevOps Build Pipeline)
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Stage 1: Managed Build │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ build_spec.yaml 실행 │ │
|
||||
│ │ │ │
|
||||
│ │ 1) IMAGE_TAG 생성 (BuildRunID + timestamp) │ │
|
||||
│ │ 2) docker build --platform linux/arm64 │ │
|
||||
│ │ - backend-java/Dockerfile → backend image │ │
|
||||
│ │ - frontend/Dockerfile → frontend image │ │
|
||||
│ │ 3) Output: BACKEND_IMAGE, FRONTEND_IMAGE │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Stage 2: Deliver Artifacts │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ Docker images → OCIR 푸시 │ │
|
||||
│ │ │ │
|
||||
│ │ icn.ocir.io/idyhsdamac8c/tasteby/backend:TAG │ │
|
||||
│ │ icn.ocir.io/idyhsdamac8c/tasteby/frontend:TAG │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
3. 배포 (수동 또는 deploy.sh)
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ kubectl set image deployment/backend backend=IMAGE:TAG │
|
||||
│ kubectl set image deployment/frontend frontend=IMAGE:TAG │
|
||||
│ kubectl rollout status ... │
|
||||
│ │
|
||||
│ git tag -a "vX.Y.Z" -m "Deploy: ..." │
|
||||
│ git push origin "vX.Y.Z" │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Git Remote 구성
|
||||
|
||||
```
|
||||
origin → Gitea (gittea.cloud-handson.com) ← 소스 코드 관리
|
||||
oci → OCI Code Repository ← CI/CD 트리거용
|
||||
```
|
||||
|
||||
두 리모트에 모두 push하여 소스와 빌드를 동기화합니다.
|
||||
|
||||
### OCI IAM 권한 설정 (빌드/배포용)
|
||||
|
||||
OCI DevOps Build Pipeline이 코드 레포, OCIR, 시크릿 등에 접근하려면 **Dynamic Group**과 **IAM Policy**가 필요합니다.
|
||||
|
||||
#### Dynamic Group
|
||||
|
||||
| 이름 | 설명 |
|
||||
|------|------|
|
||||
| `tasteby-build-pipeline` | DevOps 빌드/배포 파이프라인 리소스 |
|
||||
|
||||
**Matching Rule:**
|
||||
```
|
||||
ANY {
|
||||
resource.type = 'devopsbuildpipeline',
|
||||
resource.type = 'devopsrepository',
|
||||
resource.type = 'devopsdeploypipeline',
|
||||
resource.type = 'devopsconnection'
|
||||
}
|
||||
```
|
||||
|
||||
#### IAM Policy
|
||||
|
||||
| 이름 | 설명 |
|
||||
|------|------|
|
||||
| `tasteby-devops-policy` | DevOps 파이프라인 리소스 접근 권한 |
|
||||
|
||||
**Policy Statements:**
|
||||
```
|
||||
Allow dynamic-group tasteby-build-pipeline to manage devops-family in tenancy
|
||||
Allow dynamic-group tasteby-build-pipeline to manage repos in tenancy
|
||||
Allow dynamic-group tasteby-build-pipeline to read secret-family in tenancy
|
||||
Allow dynamic-group tasteby-build-pipeline to manage generic-artifacts in tenancy
|
||||
Allow dynamic-group tasteby-build-pipeline to use ons-topics in tenancy
|
||||
```
|
||||
|
||||
> **참고**: IAM 정책은 적용 후 전파에 최대 수 분이 걸릴 수 있습니다.
|
||||
> 빌드 실행 시 `RelatedResourceNotAuthorizedOrNotFound` 오류가 나면 정책 전파를 기다린 후 재시도하세요.
|
||||
|
||||
#### OCI Code Repository 인증 (HTTPS)
|
||||
|
||||
```
|
||||
Username: <tenancy-name>/oracleidentitycloudservice/<oci-username>
|
||||
Password: OCI Auth Token (User Settings에서 생성)
|
||||
```
|
||||
|
||||
```bash
|
||||
# Git remote 추가 예시
|
||||
git remote add oci https://devops.scmservice.ap-seoul-1.oci.oraclecloud.com/namespaces/<namespace>/projects/tasteby/repositories/tasteby
|
||||
```
|
||||
|
||||
### build_spec.yaml 구조
|
||||
|
||||
```yaml
|
||||
# OCI DevOps Build Pipeline 설정
|
||||
version: 0.1
|
||||
component: build
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
variables:
|
||||
REGISTRY: "icn.ocir.io/idyhsdamac8c/tasteby"
|
||||
exportedVariables:
|
||||
- IMAGE_TAG # 다음 stage에서 사용
|
||||
- BACKEND_IMAGE
|
||||
- FRONTEND_IMAGE
|
||||
|
||||
steps:
|
||||
- Set image tag # BuildRunID + timestamp 조합
|
||||
- Build backend image # backend-java/Dockerfile, ARM64
|
||||
- Build frontend image # frontend/Dockerfile, ARM64
|
||||
|
||||
outputArtifacts:
|
||||
- backend-image (DOCKER_IMAGE)
|
||||
- frontend-image (DOCKER_IMAGE)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 로컬 배포 (deploy.sh)
|
||||
|
||||
OCI DevOps 없이 로컬에서 직접 배포할 때 사용합니다.
|
||||
|
||||
```bash
|
||||
# 전체 배포
|
||||
./deploy.sh "초기 배포"
|
||||
|
||||
# 백엔드만
|
||||
./deploy.sh --backend-only "API 버그 수정"
|
||||
|
||||
# 프론트엔드만
|
||||
./deploy.sh --frontend-only "UI 개선"
|
||||
|
||||
# 드라이런
|
||||
./deploy.sh --dry-run "테스트"
|
||||
```
|
||||
|
||||
**deploy.sh 동작:**
|
||||
1. 최신 git tag에서 다음 버전 계산 (v0.1.X → v0.1.X+1)
|
||||
2. Docker build (ARM64) + OCIR push
|
||||
3. `kubectl set image` → rollout 대기
|
||||
4. git tag 생성 + push
|
||||
|
||||
---
|
||||
|
||||
## Docker 이미지 빌드
|
||||
|
||||
### Backend (Spring Boot)
|
||||
|
||||
```
|
||||
eclipse-temurin:21-jdk (build)
|
||||
└─ gradlew bootJar
|
||||
└─ app.jar
|
||||
|
||||
eclipse-temurin:21-jre (runtime)
|
||||
└─ java -XX:MaxRAMPercentage=75.0 -jar app.jar
|
||||
└─ EXPOSE 8000
|
||||
```
|
||||
|
||||
### Frontend (Next.js)
|
||||
|
||||
```
|
||||
node:22-alpine (build)
|
||||
└─ npm ci + npm run build
|
||||
└─ NEXT_PUBLIC_* 빌드 시 주입 (build args)
|
||||
|
||||
node:22-alpine (runtime)
|
||||
└─ .next/standalone + .next/static + public/
|
||||
└─ node server.js
|
||||
└─ EXPOSE 3001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## K8s 리소스 구성
|
||||
|
||||
### 파일 구조
|
||||
|
||||
```
|
||||
k8s/
|
||||
├── namespace.yaml # tasteby namespace
|
||||
├── configmap.yaml # 비밀이 아닌 설정
|
||||
├── secrets.yaml.template # 시크릿 템플릿 (실제 파일은 .gitignore)
|
||||
├── redis-deployment.yaml # Redis 7 + Service
|
||||
├── backend-deployment.yaml # Spring Boot + Service
|
||||
├── frontend-deployment.yaml # Next.js + Service
|
||||
├── ingress.yaml # Nginx Ingress + TLS
|
||||
└── cert-manager/
|
||||
└── cluster-issuer.yaml # Let's Encrypt ClusterIssuer
|
||||
```
|
||||
|
||||
### 리소스 할당
|
||||
|
||||
| Pod | replicas | CPU req/lim | Memory req/lim |
|
||||
|-----|----------|-------------|----------------|
|
||||
| backend | 1 | 500m / 1 | 768Mi / 1536Mi |
|
||||
| frontend | 1 | 200m / 500m | 256Mi / 512Mi |
|
||||
| redis | 1 | 100m / 200m | 128Mi / 256Mi |
|
||||
| ingress-controller | 1 | 100m / 200m | 128Mi / 256Mi |
|
||||
| cert-manager (×3) | 1 each | 50m / 100m | 64Mi / 128Mi |
|
||||
| **합계** | | **~1.2 CPU** | **~1.6GB** |
|
||||
|
||||
클러스터: ARM64 × 2 노드 (4 CPU / 16GB 총) → 여유 충분
|
||||
|
||||
### 네트워크 흐름
|
||||
|
||||
```
|
||||
Client → NLB:443 → Ingress Controller → /api/* → backend:8000
|
||||
→ /* → frontend:3001
|
||||
|
||||
backend → redis:6379 (K8s Service DNS, 클러스터 내부)
|
||||
backend → Oracle ADB (mTLS, Wallet Volume Mount)
|
||||
backend → OCI GenAI (OCI SDK, oci-config Volume Mount)
|
||||
backend → Google APIs (API Key, 환경변수)
|
||||
```
|
||||
|
||||
### Volume Mounts (backend)
|
||||
|
||||
```
|
||||
/etc/oracle/wallet/ ← Secret: oracle-wallet
|
||||
├── cwallet.sso
|
||||
├── tnsnames.ora
|
||||
├── sqlnet.ora
|
||||
├── keystore.jks
|
||||
├── truststore.jks
|
||||
└── ojdbc.properties
|
||||
|
||||
/root/.oci/ ← Secret: oci-config
|
||||
├── config
|
||||
└── oci_api_key.pem
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 환경 분리
|
||||
|
||||
```
|
||||
┌────────────────────────────┬──────────────────────────────────────┐
|
||||
│ 개발 (Local) │ 운영 (OKE) │
|
||||
├────────────────────────────┼──────────────────────────────────────┤
|
||||
│ backend/.env │ ConfigMap + Secret │
|
||||
│ frontend/.env.local │ Dockerfile build args │
|
||||
│ ~/.oci/config │ Secret: oci-config → /root/.oci/ │
|
||||
│ 로컬 Wallet 디렉토리 │ Secret: oracle-wallet → /etc/oracle/ │
|
||||
│ Redis: 192.168.0.147:6379 │ Redis: redis:6379 (K8s DNS) │
|
||||
│ PM2로 프로세스 관리 │ K8s Deployment로 관리 │
|
||||
│ nginx + certbot (SSL) │ Ingress + cert-manager (SSL) │
|
||||
└────────────────────────────┴──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**변수명이 동일**하므로 코드 변경 없이 환경만 교체 가능합니다.
|
||||
자세한 환경변수 목록은 [environment-guide.md](./environment-guide.md) 참고.
|
||||
|
||||
---
|
||||
|
||||
## OCI 리소스 정보
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| 리전 | ap-seoul-1 (Seoul) |
|
||||
| OCI 프로필 | JOUNGMINKOAWS |
|
||||
| OKE 클러스터 | tasteby-cluster |
|
||||
| OCIR Registry | icn.ocir.io/idyhsdamac8c/tasteby |
|
||||
| DevOps 프로젝트 | tasteby |
|
||||
| Code Repository | tasteby (OCI DevOps SCM) |
|
||||
| 도메인 | www.tasteby.net (Namecheap) |
|
||||
| SSL | Let's Encrypt (cert-manager HTTP-01) |
|
||||
| 노드 | ARM64 × 2대 (2 CPU / 8GB 각) |
|
||||
|
||||
---
|
||||
|
||||
## 배포 버전 관리
|
||||
|
||||
- 태그 형식: `v0.1.X` (patch 자동 증가)
|
||||
- deploy.sh가 자동으로 git tag 생성 + push
|
||||
- 태그 메시지에 배포 대상(backend/frontend)과 이미지 태그 포함
|
||||
|
||||
```bash
|
||||
# 태그 목록 확인
|
||||
git tag -l 'v*' --sort=-v:refname
|
||||
|
||||
# 특정 태그 상세 확인
|
||||
git tag -n20 v0.1.5
|
||||
|
||||
# 롤백
|
||||
kubectl rollout undo deployment/backend -n tasteby
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [OKE 배포 가이드](./oke-deployment-guide.md) — 인프라 설치 및 배포 절차
|
||||
- [환경 관리 가이드](./environment-guide.md) — 환경변수 및 시크릿 관리
|
||||
182
docs/environment-guide.md
Normal file
182
docs/environment-guide.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Tasteby 환경 관리 가이드
|
||||
|
||||
## 환경 구성 개요
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 개발 (Local) │
|
||||
│ │
|
||||
│ backend/.env ← 백엔드 환경변수 (DB, OCI, Google) │
|
||||
│ frontend/.env.local ← 프론트엔드 환경변수 (NEXT_PUBLIC_*) │
|
||||
│ ~/.oci/config ← OCI API 인증 (로컬 경로) │
|
||||
│ Oracle Wallet 디렉토리 ← DB mTLS 인증 (로컬 경로) │
|
||||
│ │
|
||||
│ PM2 → start.sh → source backend/.env → gradlew bootRun │
|
||||
│ PM2 → npm run dev (Next.js, .env.local 자동 로드) │
|
||||
│ Redis → 192.168.0.147:6379 (로컬 네트워크) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 운영 (OKE) │
|
||||
│ │
|
||||
│ k8s/configmap.yaml ← 비밀이 아닌 설정 (Redis host 등) │
|
||||
│ k8s/secrets.yaml ← 민감 정보 (DB, JWT, API keys) │
|
||||
│ Secret: oracle-wallet ← DB mTLS 인증 (Volume 마운트) │
|
||||
│ Secret: oci-config ← OCI API 인증 (Volume 마운트) │
|
||||
│ │
|
||||
│ Pod → envFrom: ConfigMap + Secret → application.yml 참조 │
|
||||
│ Redis → redis:6379 (K8s Service DNS, 클러스터 내부) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 환경변수 전체 목록
|
||||
|
||||
### 백엔드 (application.yml에서 참조)
|
||||
|
||||
| 변수명 | 설명 | 민감 | 개발 | 운영 |
|
||||
|--------|------|:----:|------|------|
|
||||
| `ORACLE_USER` | DB 사용자명 | ✅ | backend/.env | Secret |
|
||||
| `ORACLE_PASSWORD` | DB 비밀번호 | ✅ | backend/.env | Secret |
|
||||
| `ORACLE_DSN` | TNS 연결 문자열 | ✅ | backend/.env | Secret |
|
||||
| `ORACLE_WALLET` | Wallet 디렉토리 경로 | | backend/.env | ConfigMap → `/etc/oracle/wallet` |
|
||||
| `JWT_SECRET` | JWT 서명 키 | ✅ | 기본값 사용 | Secret |
|
||||
| `OCI_COMPARTMENT_ID` | OCI 컴파트먼트 ID | ✅ | backend/.env | Secret |
|
||||
| `OCI_CHAT_ENDPOINT` | GenAI Chat 엔드포인트 | | backend/.env | ConfigMap |
|
||||
| `OCI_GENAI_ENDPOINT` | GenAI Embed 엔드포인트 | | backend/.env | ConfigMap |
|
||||
| `OCI_CHAT_MODEL_ID` | Chat 모델 ID | ✅ | backend/.env | Secret |
|
||||
| `OCI_EMBED_MODEL_ID` | Embed 모델 ID | | backend/.env | ConfigMap |
|
||||
| `GOOGLE_MAPS_API_KEY` | Google Maps API 키 | ✅ | backend/.env | Secret |
|
||||
| `YOUTUBE_DATA_API_KEY` | YouTube Data API 키 | ✅ | backend/.env | Secret |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth 클라이언트 ID | | 기본값 사용 | ConfigMap |
|
||||
| `REDIS_HOST` | Redis 호스트 | | 기본값 `192.168.0.147` | ConfigMap → `redis` |
|
||||
| `REDIS_PORT` | Redis 포트 | | 기본값 `6379` | ConfigMap |
|
||||
| `REDIS_DB` | Redis DB 번호 | | 기본값 `0` | ConfigMap |
|
||||
|
||||
### 프론트엔드 (빌드 시 주입)
|
||||
|
||||
| 변수명 | 설명 | 개발 | 운영 |
|
||||
|--------|------|------|------|
|
||||
| `NEXT_PUBLIC_API_URL` | API 기본 URL | `.env.local` → 빈값 (상대경로) | Docker build arg → 빈값 |
|
||||
| `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` | Maps API 키 | `.env.local` | Docker build arg |
|
||||
| `NEXT_PUBLIC_GOOGLE_CLIENT_ID` | OAuth 클라이언트 ID | `.env.local` | Docker build arg |
|
||||
|
||||
> **참고**: `NEXT_PUBLIC_*` 변수는 Next.js 빌드 시 코드에 인라인되므로,
|
||||
> 런타임이 아닌 Docker 빌드 시점에 주입해야 합니다.
|
||||
|
||||
## 개발 vs 운영 차이점
|
||||
|
||||
### Redis 연결
|
||||
|
||||
```
|
||||
개발: REDIS_HOST=192.168.0.147 (로컬 네트워크 IP, 기본값)
|
||||
운영: REDIS_HOST=redis (K8s Service DNS, ConfigMap)
|
||||
```
|
||||
|
||||
K8s에서 Service 이름 `redis`는 클러스터 내부 DNS로 자동 해석됩니다.
|
||||
Pod IP가 변경되어도 Service가 항상 올바른 Pod를 가리킵니다.
|
||||
|
||||
```
|
||||
redis (Service, ClusterIP)
|
||||
└─ selector: app=redis
|
||||
└─ redis Pod (IP 자동 할당, 변경되어도 Service가 추적)
|
||||
```
|
||||
|
||||
### OCI 인증
|
||||
|
||||
```
|
||||
개발: ~/.oci/config → 로컬 PEM 파일 경로
|
||||
운영: /root/.oci/config → Secret Volume 마운트
|
||||
/root/.oci/oci_api_key.pem → Secret Volume 마운트
|
||||
```
|
||||
|
||||
운영용 OCI config 파일은 `key_file` 경로를 컨테이너 내부로 변경해야 합니다:
|
||||
```ini
|
||||
[DEFAULT]
|
||||
user=ocid1.user.oc1..xxx
|
||||
fingerprint=xx:xx:xx
|
||||
key_file=/root/.oci/oci_api_key.pem ← 컨테이너 내부 경로
|
||||
tenancy=ocid1.tenancy.oc1..xxx
|
||||
region=ap-seoul-1
|
||||
```
|
||||
|
||||
### Oracle DB 연결
|
||||
|
||||
```
|
||||
개발: ORACLE_WALLET=/Users/joungmin/.../Wallet_xxx (로컬 경로)
|
||||
ORACLE_DSN=tasteby_high?TNS_ADMIN=/Users/joungmin/.../Wallet_xxx
|
||||
운영: ORACLE_WALLET=/etc/oracle/wallet (Secret Volume 마운트 경로)
|
||||
ORACLE_DSN=tasteby_high?TNS_ADMIN=/etc/oracle/wallet
|
||||
```
|
||||
|
||||
## 환경별 설정 파일 매핑
|
||||
|
||||
```
|
||||
┌──────────────────┬──────────────────────┬──────────────────────────┐
|
||||
│ 설정 항목 │ 개발 (Local) │ 운영 (OKE) │
|
||||
├──────────────────┼──────────────────────┼──────────────────────────┤
|
||||
│ 백엔드 환경변수 │ backend/.env │ k8s/configmap.yaml │
|
||||
│ │ (start.sh에서 source) │ k8s/secrets.yaml │
|
||||
├──────────────────┼──────────────────────┼──────────────────────────┤
|
||||
│ 프론트엔드 환경변수│ frontend/.env.local │ Dockerfile build args │
|
||||
│ │ (Next.js 자동 로드) │ (deploy.sh에서 주입) │
|
||||
├──────────────────┼──────────────────────┼──────────────────────────┤
|
||||
│ OCI 인증 │ ~/.oci/config │ Secret: oci-config │
|
||||
│ │ (로컬 PEM 경로) │ (Volume → /root/.oci/) │
|
||||
├──────────────────┼──────────────────────┼──────────────────────────┤
|
||||
│ Oracle Wallet │ 로컬 Wallet 디렉토리 │ Secret: oracle-wallet │
|
||||
│ │ │ (Volume → /etc/oracle/ │
|
||||
│ │ │ wallet/) │
|
||||
├──────────────────┼──────────────────────┼──────────────────────────┤
|
||||
│ Redis │ 192.168.0.147:6379 │ redis:6379 │
|
||||
│ │ (로컬 네트워크) │ (K8s Service DNS) │
|
||||
├──────────────────┼──────────────────────┼──────────────────────────┤
|
||||
│ 웹서버/SSL │ nginx + Let's Encrypt │ Ingress + cert-manager │
|
||||
│ │ (로컬 certbot) │ (자동 발급/갱신) │
|
||||
└──────────────────┴──────────────────────┴──────────────────────────┘
|
||||
```
|
||||
|
||||
## 시크릿 관리 원칙
|
||||
|
||||
1. **secrets.yaml은 git에 올리지 않음** — `.gitignore`에 포함
|
||||
2. **secrets.yaml.template만 git에 올림** — 키 이름 + 구조만 공유
|
||||
3. **운영 시크릿 변경 시**:
|
||||
```bash
|
||||
# secrets.yaml 수정 후
|
||||
kubectl apply -f k8s/secrets.yaml
|
||||
kubectl rollout restart deployment/backend -n tasteby
|
||||
```
|
||||
4. **파일 기반 시크릿(Wallet, OCI config)은 kubectl로 직접 생성**:
|
||||
```bash
|
||||
kubectl create secret generic oracle-wallet --from-file=... -n tasteby
|
||||
kubectl create secret generic oci-config --from-file=... -n tasteby
|
||||
```
|
||||
|
||||
## application.yml의 환경변수 바인딩
|
||||
|
||||
Spring Boot의 `application.yml`이 환경변수를 자동으로 읽습니다:
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:oracle:thin:@${ORACLE_DSN} # ← Secret에서 주입
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:192.168.0.147} # ← ConfigMap에서 주입, 개발은 기본값
|
||||
app:
|
||||
oci:
|
||||
compartment-id: ${OCI_COMPARTMENT_ID} # ← Secret에서 주입
|
||||
```
|
||||
|
||||
K8s에서는 `envFrom`으로 ConfigMap과 Secret의 모든 키-값이 환경변수로 들어갑니다:
|
||||
|
||||
```yaml
|
||||
# backend-deployment.yaml
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: tasteby-config # REDIS_HOST, REDIS_PORT, ...
|
||||
- secretRef:
|
||||
name: tasteby-secrets # ORACLE_USER, ORACLE_PASSWORD, ...
|
||||
```
|
||||
|
||||
개발 환경에서는 동일한 변수명이 `backend/.env`에서 `source`로 로드됩니다.
|
||||
**변수명이 동일하므로 코드 변경 없이 환경만 바꿀 수 있습니다.**
|
||||
343
docs/oke-deployment-guide.md
Normal file
343
docs/oke-deployment-guide.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# Tasteby OKE 배포 가이드
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
▼
|
||||
OCI Load Balancer (Nginx Ingress가 자동 생성)
|
||||
│
|
||||
├─ / → frontend Service (Next.js :3001)
|
||||
├─ /api/ → backend Service (Spring Boot :8000)
|
||||
│
|
||||
├─ cert-manager (Let's Encrypt 인증서 자동 발급/갱신)
|
||||
└─ Redis (in-cluster 캐시 :6379)
|
||||
```
|
||||
|
||||
## 인프라 정보
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| 클러스터 | tasteby-cluster |
|
||||
| 리전 | ap-seoul-1 (Seoul) |
|
||||
| 노드 | ARM64 × 2대 (2 CPU / 8GB 각) |
|
||||
| K8s 버전 | v1.34.2 |
|
||||
| OCI 프로필 | JOUNGMINKOAWS |
|
||||
| OCIR | icn.ocir.io/idyhsdamac8c/tasteby |
|
||||
| 도메인 | www.tasteby.net (Namecheap DNS) |
|
||||
| SSL | Let's Encrypt (cert-manager + HTTP-01) |
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
tasteby/
|
||||
├── backend-java/Dockerfile
|
||||
├── frontend/Dockerfile
|
||||
├── k8s/
|
||||
│ ├── namespace.yaml
|
||||
│ ├── configmap.yaml
|
||||
│ ├── secrets.yaml.template ← 실제 secrets.yaml은 .gitignore
|
||||
│ ├── redis-deployment.yaml
|
||||
│ ├── backend-deployment.yaml
|
||||
│ ├── frontend-deployment.yaml
|
||||
│ ├── ingress.yaml
|
||||
│ └── cert-manager/
|
||||
│ └── cluster-issuer.yaml
|
||||
└── deploy.sh
|
||||
```
|
||||
|
||||
## 리소스 배분
|
||||
|
||||
| 파드 | replicas | CPU req/lim | 메모리 req/lim |
|
||||
|------|----------|-------------|----------------|
|
||||
| backend (Java) | 1 | 500m / 1 | 768Mi / 1536Mi |
|
||||
| frontend (Next.js) | 1 | 200m / 500m | 256Mi / 512Mi |
|
||||
| redis | 1 | 100m / 200m | 128Mi / 256Mi |
|
||||
| ingress-controller | 1 | 100m / 200m | 128Mi / 256Mi |
|
||||
| cert-manager (×3) | 1씩 | 50m / 100m | 64Mi / 128Mi |
|
||||
| **합계** | | **~1.2 CPU** | **~1.6GB** |
|
||||
|
||||
전체 클러스터: 4 CPU / 16GB → 여유 충분
|
||||
|
||||
---
|
||||
|
||||
## 1단계: 사전 준비
|
||||
|
||||
### 1.1 kubectl 설정
|
||||
|
||||
```bash
|
||||
# OKE kubeconfig 가져오기
|
||||
oci ce cluster create-kubeconfig \
|
||||
--cluster-id ocid1.cluster.oc1.ap-seoul-1.aaaaaaaaoqgd2sh6754m5zrwfqaxwrtlqon3dxtdwbbc2dvzbcbou3pf75rq \
|
||||
--profile JOUNGMINKOAWS \
|
||||
--region ap-seoul-1 \
|
||||
--token-version 2.0.0 \
|
||||
--kube-endpoint PUBLIC_ENDPOINT
|
||||
|
||||
# ~/.kube/config의 user args에 --profile JOUNGMINKOAWS 추가 필요
|
||||
# 확인
|
||||
kubectl get nodes
|
||||
```
|
||||
|
||||
### 1.2 Helm 설치 (없으면)
|
||||
|
||||
```bash
|
||||
brew install helm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2단계: 인프라 설치 (1회성)
|
||||
|
||||
### 2.1 Nginx Ingress Controller
|
||||
|
||||
```bash
|
||||
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
|
||||
helm repo update
|
||||
|
||||
helm install ingress-nginx ingress-nginx/ingress-nginx \
|
||||
--namespace ingress-nginx --create-namespace \
|
||||
--set controller.service.type=LoadBalancer \
|
||||
--set controller.service.annotations."oci\.oraclecloud\.com/load-balancer-type"=nlb
|
||||
```
|
||||
|
||||
설치 후 External IP 확인:
|
||||
```bash
|
||||
kubectl get svc -n ingress-nginx ingress-nginx-controller -w
|
||||
# EXTERNAL-IP가 나오면 Namecheap에서 A 레코드 업데이트
|
||||
# www.tasteby.net → <EXTERNAL-IP>
|
||||
# tasteby.net → <EXTERNAL-IP>
|
||||
```
|
||||
|
||||
### 2.2 cert-manager
|
||||
|
||||
```bash
|
||||
helm repo add jetstack https://charts.jetstack.io
|
||||
helm repo update
|
||||
|
||||
helm install cert-manager jetstack/cert-manager \
|
||||
--namespace cert-manager --create-namespace \
|
||||
--set crds.enabled=true
|
||||
```
|
||||
|
||||
### 2.3 ClusterIssuer 생성
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/cert-manager/cluster-issuer.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3단계: 네임스페이스 및 시크릿 생성 (1회성)
|
||||
|
||||
### 3.1 네임스페이스
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
```
|
||||
|
||||
### 3.2 OCIR 이미지 Pull Secret
|
||||
|
||||
```bash
|
||||
# OCI Auth Token 필요 (콘솔 > User Settings > Auth Tokens에서 생성)
|
||||
kubectl create secret docker-registry ocir-secret \
|
||||
--docker-server=icn.ocir.io \
|
||||
--docker-username="idyhsdamac8c/<oci-username>" \
|
||||
--docker-password="<auth-token>" \
|
||||
--docker-email="<email>" \
|
||||
-n tasteby
|
||||
```
|
||||
|
||||
### 3.3 앱 시크릿
|
||||
|
||||
```bash
|
||||
# secrets.yaml.template을 복사하여 실제 값 입력
|
||||
cp k8s/secrets.yaml.template k8s/secrets.yaml
|
||||
# 편집 후
|
||||
kubectl apply -f k8s/secrets.yaml
|
||||
```
|
||||
|
||||
### 3.4 Oracle Wallet Secret
|
||||
|
||||
```bash
|
||||
# Wallet 디렉토리의 파일들을 Secret으로 생성
|
||||
kubectl create secret generic oracle-wallet \
|
||||
--from-file=cwallet.sso=<wallet-dir>/cwallet.sso \
|
||||
--from-file=tnsnames.ora=<wallet-dir>/tnsnames.ora \
|
||||
--from-file=sqlnet.ora=<wallet-dir>/sqlnet.ora \
|
||||
--from-file=keystore.jks=<wallet-dir>/keystore.jks \
|
||||
--from-file=truststore.jks=<wallet-dir>/truststore.jks \
|
||||
--from-file=ojdbc.properties=<wallet-dir>/ojdbc.properties \
|
||||
-n tasteby
|
||||
```
|
||||
|
||||
### 3.5 OCI Config Secret
|
||||
|
||||
```bash
|
||||
# OCI API key config 파일과 PEM 키를 Secret으로 생성
|
||||
# config 파일은 K8s용으로 수정 필요 (key_file 경로를 /root/.oci/oci_api_key.pem으로)
|
||||
kubectl create secret generic oci-config \
|
||||
--from-file=config=<oci-config-for-k8s> \
|
||||
--from-file=oci_api_key.pem=<pem-key-file> \
|
||||
-n tasteby
|
||||
```
|
||||
|
||||
**참고**: OCI config 파일에서 `key_file` 경로를 컨테이너 내부 마운트 경로로 수정:
|
||||
```ini
|
||||
[DEFAULT]
|
||||
user=ocid1.user.oc1..xxx
|
||||
fingerprint=xx:xx:xx
|
||||
key_file=/root/.oci/oci_api_key.pem
|
||||
tenancy=ocid1.tenancy.oc1..xxx
|
||||
region=ap-seoul-1
|
||||
```
|
||||
|
||||
### 3.6 ConfigMap 적용
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/configmap.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4단계: 앱 배포
|
||||
|
||||
### 4.1 기본 리소스 배포
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/redis-deployment.yaml
|
||||
kubectl apply -f k8s/backend-deployment.yaml
|
||||
kubectl apply -f k8s/frontend-deployment.yaml
|
||||
kubectl apply -f k8s/ingress.yaml
|
||||
```
|
||||
|
||||
### 4.2 OCIR 로그인 (이미지 푸시용)
|
||||
|
||||
```bash
|
||||
docker login icn.ocir.io \
|
||||
-u "idyhsdamac8c/<oci-username>" \
|
||||
-p "<auth-token>"
|
||||
```
|
||||
|
||||
### 4.3 이미지 빌드 & 배포
|
||||
|
||||
```bash
|
||||
# 전체 배포
|
||||
./deploy.sh "초기 배포"
|
||||
|
||||
# 백엔드만 배포
|
||||
./deploy.sh --backend-only "API 버그 수정"
|
||||
|
||||
# 프론트엔드만 배포
|
||||
./deploy.sh --frontend-only "UI 개선"
|
||||
|
||||
# 드라이런 (실제 실행 안 함)
|
||||
./deploy.sh --dry-run "테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5단계: DNS 설정
|
||||
|
||||
Namecheap에서 A 레코드 변경:
|
||||
|
||||
| Type | Host | Value | TTL |
|
||||
|------|------|-------|-----|
|
||||
| A | @ | `<LB External IP>` | Automatic |
|
||||
| A | www | `<LB External IP>` | Automatic |
|
||||
|
||||
DNS 전파 후 cert-manager가 자동으로 Let's Encrypt 인증서를 발급합니다.
|
||||
|
||||
---
|
||||
|
||||
## 운영 명령어
|
||||
|
||||
### 상태 확인
|
||||
|
||||
```bash
|
||||
# 파드 상태
|
||||
kubectl get pods -n tasteby
|
||||
|
||||
# 로그 확인
|
||||
kubectl logs -f deployment/backend -n tasteby
|
||||
kubectl logs -f deployment/frontend -n tasteby
|
||||
|
||||
# 인증서 상태
|
||||
kubectl get certificate -n tasteby
|
||||
kubectl describe certificate tasteby-tls -n tasteby
|
||||
```
|
||||
|
||||
### 롤백
|
||||
|
||||
```bash
|
||||
# 이전 버전으로 롤백
|
||||
kubectl rollout undo deployment/backend -n tasteby
|
||||
kubectl rollout undo deployment/frontend -n tasteby
|
||||
|
||||
# 특정 리비전으로 롤백
|
||||
kubectl rollout history deployment/backend -n tasteby
|
||||
kubectl rollout undo deployment/backend --to-revision=2 -n tasteby
|
||||
```
|
||||
|
||||
### 시크릿 업데이트
|
||||
|
||||
```bash
|
||||
# secrets.yaml 수정 후
|
||||
kubectl apply -f k8s/secrets.yaml
|
||||
# 파드 재시작 (시크릿 변경 반영)
|
||||
kubectl rollout restart deployment/backend -n tasteby
|
||||
```
|
||||
|
||||
### 스케일링
|
||||
|
||||
```bash
|
||||
# 백엔드 2개로 확장
|
||||
kubectl scale deployment/backend --replicas=2 -n tasteby
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 배포 태그 규칙
|
||||
|
||||
- 형식: `v0.1.X` (patch 버전 자동 증가)
|
||||
- `deploy.sh`가 빌드 → 푸시 → K8s 업데이트 → git tag 생성 → 태그 푸시까지 자동 처리
|
||||
- 태그 메시지에 배포 대상(backend/frontend)과 이미지 태그 포함
|
||||
|
||||
```bash
|
||||
# 태그 목록 확인
|
||||
git tag -l 'v*' --sort=-v:refname
|
||||
|
||||
# 특정 태그의 배포 내역 확인
|
||||
git tag -n20 v0.1.5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
### 이미지 Pull 실패
|
||||
```bash
|
||||
kubectl describe pod <pod-name> -n tasteby
|
||||
# Events에서 ImagePullBackOff 확인 → ocir-secret 점검
|
||||
```
|
||||
|
||||
### DB 연결 실패
|
||||
```bash
|
||||
kubectl exec -it deployment/backend -n tasteby -- env | grep ORACLE
|
||||
# Oracle Wallet 마운트 확인
|
||||
kubectl exec -it deployment/backend -n tasteby -- ls /etc/oracle/wallet/
|
||||
```
|
||||
|
||||
### 인증서 발급 안 됨
|
||||
```bash
|
||||
kubectl get challenges -n tasteby
|
||||
kubectl describe challenge -n tasteby
|
||||
# DNS A 레코드가 LB IP로 설정되었는지, 80 포트가 열려있는지 확인
|
||||
```
|
||||
|
||||
### OCI GenAI 연결 실패
|
||||
```bash
|
||||
kubectl exec -it deployment/backend -n tasteby -- cat /root/.oci/config
|
||||
# key_file 경로가 /root/.oci/oci_api_key.pem 인지 확인
|
||||
```
|
||||
239
docs/troubleshooting.md
Normal file
239
docs/troubleshooting.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Tasteby 배포 트러블슈팅 기록
|
||||
|
||||
## 1. OCI DevOps Build Pipeline - 코드 접근 권한 오류
|
||||
|
||||
**오류:**
|
||||
```
|
||||
Unable to fetch build_spec file build_spec.yaml due to RelatedResourceNotAuthorizedOrNotFound.
|
||||
Please check if dynamic groups and the corresponding policies are properly configured.
|
||||
```
|
||||
|
||||
**원인:** OCI DevOps Build Pipeline이 Code Repository에 접근할 IAM 권한이 없음
|
||||
|
||||
**해결:**
|
||||
1. Dynamic Group 생성 — 빌드 파이프라인 리소스를 포함하는 매칭 룰:
|
||||
```
|
||||
ANY {
|
||||
resource.type = 'devopsbuildpipeline',
|
||||
resource.type = 'devopsrepository',
|
||||
resource.type = 'devopsdeploypipeline',
|
||||
resource.type = 'devopsconnection'
|
||||
}
|
||||
```
|
||||
2. IAM Policy 생성:
|
||||
```
|
||||
Allow dynamic-group tasteby-build-pipeline to manage devops-family in tenancy
|
||||
Allow dynamic-group tasteby-build-pipeline to manage repos in tenancy
|
||||
Allow dynamic-group tasteby-build-pipeline to read secret-family in tenancy
|
||||
Allow dynamic-group tasteby-build-pipeline to manage generic-artifacts in tenancy
|
||||
Allow dynamic-group tasteby-build-pipeline to use ons-topics in tenancy
|
||||
```
|
||||
3. IAM 정책 전파에 수 분 소요 — 적용 후 바로 빌드하면 동일 오류 발생할 수 있음
|
||||
|
||||
---
|
||||
|
||||
## 2. OCI DevOps Build Pipeline - Logs 미설정
|
||||
|
||||
**오류:**
|
||||
```
|
||||
Logs need to be enabled in order to run the builds.
|
||||
Please enable logs for your project.
|
||||
```
|
||||
|
||||
**원인:** DevOps 프로젝트에 OCI Logging이 설정되지 않음
|
||||
|
||||
**해결:**
|
||||
1. OCI Logging > Log Group 생성 (예: `tasteby-devops-logs`)
|
||||
2. Log Group에 Service Log 생성:
|
||||
- Source: `devops` 서비스
|
||||
- Resource: DevOps 프로젝트 OCID
|
||||
- Category: `all`
|
||||
3. DevOps 프로젝트에 Notification Topic 설정 필요
|
||||
|
||||
---
|
||||
|
||||
## 3. OCI DevOps Build Pipeline - ARM64 이미지 빌드 불가
|
||||
|
||||
**오류:**
|
||||
```
|
||||
Step 'Step_CommandV1_2' failed with exit code: '1' (docker build --platform linux/arm64)
|
||||
Step 'Step_CommandV1_1' failed with exit code: '125' (QEMU 설정 시도)
|
||||
```
|
||||
|
||||
**원인:**
|
||||
- OCI DevOps Managed Build는 x86_64 러너만 제공 (`OL7_X86_64_STANDARD_10`)
|
||||
- ARM64 이미지를 직접 빌드할 수 없음
|
||||
- `--privileged` 모드가 허용되지 않아 QEMU 크로스빌드도 불가
|
||||
|
||||
**해결:**
|
||||
- Colima (macOS 경량 Docker) 설치로 로컬 ARM64 빌드:
|
||||
```bash
|
||||
brew install colima docker
|
||||
colima start --arch aarch64 --cpu 2 --memory 4
|
||||
```
|
||||
- deploy.sh로 로컬 빌드 → OCIR push → K8s 배포
|
||||
|
||||
---
|
||||
|
||||
## 4. OCI Code Repository - HTTPS 인증 실패
|
||||
|
||||
**오류:**
|
||||
```
|
||||
fatal: Authentication failed for 'https://devops.scmservice.ap-seoul-1.oci.oraclecloud.com/...'
|
||||
```
|
||||
|
||||
**원인:** OCI Code Repository HTTPS 인증의 username 형식이 특수함
|
||||
|
||||
**해결:**
|
||||
- IDCS 연동 사용자의 경우 username 형식:
|
||||
```
|
||||
<tenancy-name>/oracleidentitycloudservice/<oci-username>
|
||||
```
|
||||
예시: `joungminkoaws/oracleidentitycloudservice/joungmin.ko.aws@gmail.com`
|
||||
- Password: OCI Auth Token (User Settings > Auth Tokens에서 생성)
|
||||
- `idyhsdamac8c` (namespace)가 아닌 `joungminkoaws` (tenancy name)을 사용해야 함
|
||||
|
||||
---
|
||||
|
||||
## 5. Docker 빌드 컨텍스트 과대 (276MB)
|
||||
|
||||
**증상:**
|
||||
```
|
||||
Sending build context to Docker daemon 276.4MB
|
||||
```
|
||||
|
||||
**원인:** `.dockerignore` 파일이 없어 `build/`, `.gradle/`, `node_modules/` 등이 포함됨
|
||||
|
||||
**해결:**
|
||||
- `backend-java/.dockerignore` 생성:
|
||||
```
|
||||
build/
|
||||
.gradle/
|
||||
.idea/
|
||||
*.iml
|
||||
```
|
||||
- `frontend/.dockerignore` 생성:
|
||||
```
|
||||
node_modules/
|
||||
.next/
|
||||
.env.local
|
||||
```
|
||||
- 결과: 276MB → 336KB (backend), 602KB (frontend)
|
||||
|
||||
---
|
||||
|
||||
## 6. Redis ImageInspectError (OKE CRI-O)
|
||||
|
||||
**오류:**
|
||||
```
|
||||
Failed to inspect image "": rpc error: code = Unknown desc = short name mode is enforcing,
|
||||
but image name redis:7-alpine returns ambiguous list
|
||||
```
|
||||
|
||||
**원인:** OKE는 CRI-O 컨테이너 런타임을 사용하며, short name (예: `redis:7-alpine`)을 허용하지 않음
|
||||
|
||||
**해결:**
|
||||
- 이미지명에 full registry prefix 추가:
|
||||
```yaml
|
||||
# 변경 전
|
||||
image: redis:7-alpine
|
||||
# 변경 후
|
||||
image: docker.io/library/redis:7-alpine
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. OCIR ImagePullBackOff (K8s)
|
||||
|
||||
**오류:**
|
||||
```
|
||||
Failed to pull image "icn.ocir.io/.../backend:latest": unable to retrieve auth token:
|
||||
invalid username/password: unknown: Unauthorized
|
||||
```
|
||||
|
||||
**원인:** K8s `ocir-secret`의 username 형식이 잘못됨
|
||||
|
||||
**해결:**
|
||||
- IDCS 사용자의 경우 OCIR pull secret 생성 시:
|
||||
```bash
|
||||
kubectl create secret docker-registry ocir-secret \
|
||||
--docker-server=icn.ocir.io \
|
||||
--docker-username='<namespace>/oracleidentitycloudservice/<username>' \
|
||||
--docker-password='<auth-token>' \
|
||||
-n tasteby
|
||||
```
|
||||
- Docker login 시에도 동일한 형식:
|
||||
```bash
|
||||
docker login icn.ocir.io \
|
||||
-u "<namespace>/oracleidentitycloudservice/<username>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. kubectl 인증 실패
|
||||
|
||||
**오류:**
|
||||
```
|
||||
error: You must be logged in to the server (Unauthorized)
|
||||
```
|
||||
|
||||
**원인:** kubeconfig 생성 시 OCI 프로필이 지정되지 않음
|
||||
|
||||
**해결:**
|
||||
- `~/.kube/config`의 user args에 `--profile JOUNGMINKOAWS` 추가:
|
||||
```yaml
|
||||
users:
|
||||
- name: user-xxx
|
||||
user:
|
||||
exec:
|
||||
args:
|
||||
- ce
|
||||
- cluster
|
||||
- generate-token
|
||||
- --cluster-id
|
||||
- ocid1.cluster.oc1...
|
||||
- --region
|
||||
- ap-seoul-1
|
||||
- --profile # ← 추가
|
||||
- JOUNGMINKOAWS # ← 추가
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Let's Encrypt 인증서 발급 실패 - Timeout during connect
|
||||
|
||||
**오류:**
|
||||
```
|
||||
acme: authorization error for www.tasteby.net: 400 urn:ietf:params:acme:error:connection:
|
||||
64.110.90.89: Timeout during connect (likely firewall problem)
|
||||
```
|
||||
|
||||
**원인:** OKE VCN의 Security List에서 LB 서브넷으로의 80/443 포트가 열려있지 않음
|
||||
|
||||
**해결:**
|
||||
1. **LB 서브넷 Security List**에 Ingress 규칙 추가:
|
||||
- `0.0.0.0/0` → TCP 80 (HTTP)
|
||||
- `0.0.0.0/0` → TCP 443 (HTTPS)
|
||||
- Egress: `0.0.0.0/0` → All protocols
|
||||
|
||||
2. **노드 서브넷 Security List**에 LB→노드 Ingress 규칙 추가:
|
||||
- `10.0.20.0/24` (LB 서브넷 CIDR) → TCP 30000-32767 (NodePort)
|
||||
- `10.0.20.0/24` → TCP 10256 (Health check)
|
||||
|
||||
3. 인증서 재발급:
|
||||
```bash
|
||||
kubectl delete certificate tasteby-tls -n tasteby
|
||||
kubectl apply -f k8s/ingress.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. tasteby.net (root domain) DNS 미전파
|
||||
|
||||
**증상:** www.tasteby.net은 되지만 tasteby.net challenge가 `pending` 상태
|
||||
|
||||
**원인:** Namecheap에서 @ (root) A 레코드가 설정되지 않았거나 전파가 안 됨
|
||||
|
||||
**해결:**
|
||||
- Ingress TLS에서 www.tasteby.net만 먼저 설정하여 인증서 발급
|
||||
- root domain DNS 전파 완료 후 TLS hosts에 tasteby.net 추가하고 인증서 재발급
|
||||
@@ -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",
|
||||
|
||||
3
frontend/.dockerignore
Normal file
3
frontend/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.env.local
|
||||
19
frontend/Dockerfile
Normal file
19
frontend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
# ── Build stage ──
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
ARG NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
|
||||
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
RUN npm run build
|
||||
|
||||
# ── Runtime stage ──
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/.next/standalone ./
|
||||
COPY --from=build /app/.next/static ./.next/static
|
||||
COPY --from=build /app/public ./public
|
||||
EXPOSE 3001
|
||||
ENV PORT=3001 HOSTNAME=0.0.0.0
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
BIN
frontend/public/icon.jpg
Normal file
BIN
frontend/public/icon.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -29,7 +29,7 @@ export default function AdminPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 text-gray-900">
|
||||
<header className="bg-white border-b px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -133,19 +133,19 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
placeholder="YouTube Channel ID"
|
||||
value={newId}
|
||||
onChange={(e) => setNewId(e.target.value)}
|
||||
className="border rounded px-3 py-2 flex-1 text-sm"
|
||||
className="border rounded px-3 py-2 flex-1 text-sm bg-white text-gray-900"
|
||||
/>
|
||||
<input
|
||||
placeholder="채널 이름"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="border rounded px-3 py-2 flex-1 text-sm"
|
||||
className="border rounded px-3 py-2 flex-1 text-sm bg-white text-gray-900"
|
||||
/>
|
||||
<input
|
||||
placeholder="제목 필터 (선택)"
|
||||
value={newFilter}
|
||||
onChange={(e) => setNewFilter(e.target.value)}
|
||||
className="border rounded px-3 py-2 w-40 text-sm"
|
||||
className="border rounded px-3 py-2 w-40 text-sm bg-white text-gray-900"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
@@ -159,7 +159,7 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">채널 이름</th>
|
||||
<th className="text-left px-4 py-3">Channel ID</th>
|
||||
@@ -282,6 +282,8 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
const [vectorProgress, setVectorProgress] = useState<{ phase: string; current: number; total: number; name?: string } | null>(null);
|
||||
const [remappingCuisine, setRemappingCuisine] = useState(false);
|
||||
const [remapProgress, setRemapProgress] = useState<{ current: number; total: number; updated: number } | null>(null);
|
||||
const [remappingFoods, setRemappingFoods] = useState(false);
|
||||
const [foodsProgress, setFoodsProgress] = useState<{ current: number; total: number; updated: number } | null>(null);
|
||||
const [bulkProgress, setBulkProgress] = useState<{
|
||||
label: string;
|
||||
total: number;
|
||||
@@ -391,11 +393,16 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
}
|
||||
};
|
||||
|
||||
const startBulkStream = async (mode: "transcript" | "extract") => {
|
||||
const startBulkStream = async (mode: "transcript" | "extract", ids?: string[]) => {
|
||||
const isTranscript = mode === "transcript";
|
||||
const setRunning = isTranscript ? setBulkTranscripting : setBulkExtracting;
|
||||
const hasSelection = ids && ids.length > 0;
|
||||
|
||||
try {
|
||||
let count: number;
|
||||
if (hasSelection) {
|
||||
count = ids.length;
|
||||
} else {
|
||||
const pending = isTranscript
|
||||
? await api.getBulkTranscriptPending()
|
||||
: await api.getBulkExtractPending();
|
||||
@@ -403,23 +410,29 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
alert(isTranscript ? "자막 없는 영상이 없습니다" : "추출 대기 중인 영상이 없습니다");
|
||||
return;
|
||||
}
|
||||
count = pending.count;
|
||||
}
|
||||
const msg = isTranscript
|
||||
? `자막 없는 영상 ${pending.count}개의 트랜스크립트를 수집하시겠습니까?\n(영상 당 5~15초 랜덤 딜레이)`
|
||||
: `LLM 추출이 안된 영상 ${pending.count}개를 벌크 처리하시겠습니까?\n(영상 당 3~8초 랜덤 딜레이)`;
|
||||
? `${hasSelection ? "선택한 " : "자막 없는 "}영상 ${count}개의 트랜스크립트를 수집하시겠습니까?`
|
||||
: `${hasSelection ? "선택한 " : "LLM 추출이 안된 "}영상 ${count}개를 벌크 처리하시겠습니까?`;
|
||||
if (!confirm(msg)) return;
|
||||
|
||||
setRunning(true);
|
||||
setBulkProgress({
|
||||
label: isTranscript ? "벌크 자막 수집" : "벌크 LLM 추출",
|
||||
total: pending.count, current: 0, currentTitle: "", results: [],
|
||||
total: count, current: 0, currentTitle: "", results: [],
|
||||
});
|
||||
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
const endpoint = isTranscript ? "/api/videos/bulk-transcript" : "/api/videos/bulk-extract";
|
||||
const token = typeof window !== "undefined" ? localStorage.getItem("tasteby_token") : null;
|
||||
const headers: Record<string, string> = {};
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
const resp = await fetch(`${apiBase}${endpoint}`, { method: "POST", headers });
|
||||
const resp = await fetch(`${apiBase}${endpoint}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: hasSelection ? JSON.stringify({ ids }) : undefined,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert(`벌크 요청 실패: ${resp.status} ${resp.statusText}`);
|
||||
setRunning(false);
|
||||
@@ -556,6 +569,51 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
}
|
||||
};
|
||||
|
||||
const startRemapFoods = async () => {
|
||||
if (!confirm("전체 식당의 메뉴 태그를 LLM으로 재생성합니다 (한글, 최대 10개). 진행하시겠습니까?")) return;
|
||||
setRemappingFoods(true);
|
||||
setFoodsProgress(null);
|
||||
try {
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
const token = typeof window !== "undefined" ? localStorage.getItem("tasteby_token") : null;
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
const resp = await fetch(`${apiBase}/api/videos/remap-foods`, { method: "POST", headers });
|
||||
if (!resp.ok) {
|
||||
alert(`메뉴 태그 재생성 실패: ${resp.status}`);
|
||||
setRemappingFoods(false);
|
||||
return;
|
||||
}
|
||||
const reader = resp.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
if (!reader) { setRemappingFoods(false); return; }
|
||||
let buf = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
try {
|
||||
const ev = JSON.parse(line.slice(6));
|
||||
if (ev.type === "processing" || ev.type === "batch_done") {
|
||||
setFoodsProgress({ current: ev.current, total: ev.total, updated: ev.updated || 0 });
|
||||
} else if (ev.type === "complete") {
|
||||
setFoodsProgress({ current: ev.total, total: ev.total, updated: ev.updated });
|
||||
} else if (ev.type === "error") {
|
||||
alert(`메뉴 태그 재생성 오류: ${ev.message}`);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
setRemappingFoods(false);
|
||||
} catch {
|
||||
setRemappingFoods(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (key: VideoSortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortAsc(!sortAsc);
|
||||
@@ -614,7 +672,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<select
|
||||
value={channelFilter}
|
||||
onChange={(e) => { setChannelFilter(e.target.value); setPage(0); }}
|
||||
className="border rounded px-3 py-2 text-sm"
|
||||
className="border rounded px-3 py-2 text-sm bg-white text-gray-900"
|
||||
>
|
||||
<option value="">전체 채널</option>
|
||||
{channels.map((ch) => (
|
||||
@@ -624,7 +682,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="border rounded px-3 py-2 text-sm"
|
||||
className="border rounded px-3 py-2 text-sm bg-white text-gray-900"
|
||||
>
|
||||
<option value="">전체 상태</option>
|
||||
<option value="pending">대기중</option>
|
||||
@@ -640,7 +698,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
value={titleSearch}
|
||||
onChange={(e) => { setTitleSearch(e.target.value); setPage(0); }}
|
||||
onKeyDown={(e) => e.key === "Escape" && setTitleSearch("")}
|
||||
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48"
|
||||
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-white text-gray-900"
|
||||
/>
|
||||
{titleSearch ? (
|
||||
<button
|
||||
@@ -697,12 +755,33 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
>
|
||||
{remappingCuisine ? "음식분류 중..." : "음식종류 재분류"}
|
||||
</button>
|
||||
<button
|
||||
onClick={startRemapFoods}
|
||||
disabled={remappingFoods || bulkExtracting || bulkTranscripting || rebuildingVectors || remappingCuisine}
|
||||
className="bg-orange-600 text-white px-4 py-2 rounded text-sm hover:bg-orange-700 disabled:opacity-50"
|
||||
>
|
||||
{remappingFoods ? "메뉴태그 재생성 중..." : "메뉴태그 재생성"}
|
||||
</button>
|
||||
</>}
|
||||
{processResult && (
|
||||
<span className="text-sm text-gray-600">{processResult}</span>
|
||||
)}
|
||||
{isAdmin && selected.size > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => startBulkStream("transcript", Array.from(selected))}
|
||||
disabled={bulkTranscripting || bulkExtracting}
|
||||
className="bg-orange-500 text-white px-4 py-2 rounded text-sm hover:bg-orange-600 disabled:opacity-50"
|
||||
>
|
||||
선택 자막 수집 ({selected.size})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => startBulkStream("extract", Array.from(selected))}
|
||||
disabled={bulkExtracting || bulkTranscripting}
|
||||
className="bg-purple-500 text-white px-4 py-2 rounded text-sm hover:bg-purple-600 disabled:opacity-50"
|
||||
>
|
||||
선택 LLM 추출 ({selected.size})
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkSkip}
|
||||
className="bg-gray-500 text-white px-4 py-2 rounded text-sm hover:bg-gray-600"
|
||||
@@ -725,7 +804,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-auto min-w-[800px]">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
|
||||
<tr>
|
||||
<th className="px-4 py-3 w-8">
|
||||
<input
|
||||
@@ -908,6 +987,24 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 메뉴태그 재생성 진행 */}
|
||||
{foodsProgress && (
|
||||
<div className="mt-4 bg-white rounded-lg shadow p-4">
|
||||
<h4 className="font-semibold text-sm mb-2">
|
||||
메뉴태그 재생성 {foodsProgress.current >= foodsProgress.total ? "완료" : "진행 중"}
|
||||
</h4>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mb-2">
|
||||
<div
|
||||
className="bg-orange-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${foodsProgress.total ? (foodsProgress.current / foodsProgress.total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{foodsProgress.current}/{foodsProgress.total} — {foodsProgress.updated}개 업데이트
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 벡터 재생성 진행 */}
|
||||
{vectorProgress && (
|
||||
<div className="mt-4 bg-white rounded-lg shadow p-4">
|
||||
@@ -1115,34 +1212,34 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500">식당명 *</label>
|
||||
<input value={manualForm.name} onChange={(e) => setManualForm(f => ({ ...f, name: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" placeholder="식당 이름" />
|
||||
<input value={manualForm.name} onChange={(e) => setManualForm(f => ({ ...f, name: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="식당 이름" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500">주소</label>
|
||||
<input value={manualForm.address} onChange={(e) => setManualForm(f => ({ ...f, address: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" placeholder="주소 (없으면 지역)" />
|
||||
<input value={manualForm.address} onChange={(e) => setManualForm(f => ({ ...f, address: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="주소 (없으면 지역)" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500">지역</label>
|
||||
<input value={manualForm.region} onChange={(e) => setManualForm(f => ({ ...f, region: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" placeholder="서울 강남" />
|
||||
<input value={manualForm.region} onChange={(e) => setManualForm(f => ({ ...f, region: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="서울 강남" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500">음식 종류</label>
|
||||
<input value={manualForm.cuisine_type} onChange={(e) => setManualForm(f => ({ ...f, cuisine_type: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" placeholder="한식, 일식..." />
|
||||
<input value={manualForm.cuisine_type} onChange={(e) => setManualForm(f => ({ ...f, cuisine_type: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="한식, 일식..." />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500">메뉴</label>
|
||||
<input value={manualForm.foods_mentioned} onChange={(e) => setManualForm(f => ({ ...f, foods_mentioned: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" placeholder="메뉴1, 메뉴2" />
|
||||
<input value={manualForm.foods_mentioned} onChange={(e) => setManualForm(f => ({ ...f, foods_mentioned: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="메뉴1, 메뉴2" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500">게스트</label>
|
||||
<input value={manualForm.guests} onChange={(e) => setManualForm(f => ({ ...f, guests: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" placeholder="게스트1, 게스트2" />
|
||||
<input value={manualForm.guests} onChange={(e) => setManualForm(f => ({ ...f, guests: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="게스트1, 게스트2" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-gray-500">평가/요약</label>
|
||||
<textarea value={manualForm.evaluation} onChange={(e) => setManualForm(f => ({ ...f, evaluation: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" rows={2} placeholder="맛집 평가 내용" />
|
||||
<textarea value={manualForm.evaluation} onChange={(e) => setManualForm(f => ({ ...f, evaluation: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" rows={2} placeholder="맛집 평가 내용" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -1185,7 +1282,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="w-full border rounded p-2 text-xs font-mono mb-2 bg-gray-50"
|
||||
className="w-full border rounded p-2 text-xs font-mono mb-2 bg-white text-gray-900"
|
||||
rows={12}
|
||||
placeholder="프롬프트 템플릿 ({title}, {transcript} 변수 사용)"
|
||||
/>
|
||||
@@ -1199,39 +1296,39 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">이름</label>
|
||||
<input value={editRest.name} onChange={(e) => setEditRest({ ...editRest, name: e.target.value })} className="w-full border rounded px-2 py-1 text-sm" />
|
||||
<input value={editRest.name} onChange={(e) => setEditRest({ ...editRest, name: e.target.value })} className="w-full border rounded px-2 py-1 text-sm bg-white text-gray-900" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">종류</label>
|
||||
<input value={editRest.cuisine_type} onChange={(e) => setEditRest({ ...editRest, cuisine_type: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" />
|
||||
<input value={editRest.cuisine_type} onChange={(e) => setEditRest({ ...editRest, cuisine_type: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">가격대</label>
|
||||
<input value={editRest.price_range} onChange={(e) => setEditRest({ ...editRest, price_range: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" />
|
||||
<input value={editRest.price_range} onChange={(e) => setEditRest({ ...editRest, price_range: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">지역</label>
|
||||
<input value={editRest.region} onChange={(e) => setEditRest({ ...editRest, region: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" />
|
||||
<input value={editRest.region} onChange={(e) => setEditRest({ ...editRest, region: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">주소</label>
|
||||
<input value={editRest.address} onChange={(e) => setEditRest({ ...editRest, address: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" />
|
||||
<input value={editRest.address} onChange={(e) => setEditRest({ ...editRest, address: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">메뉴 (쉼표 구분)</label>
|
||||
<input value={editRest.foods_mentioned} onChange={(e) => setEditRest({ ...editRest, foods_mentioned: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" placeholder="메뉴1, 메뉴2, ..." />
|
||||
<input value={editRest.foods_mentioned} onChange={(e) => setEditRest({ ...editRest, foods_mentioned: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="메뉴1, 메뉴2, ..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">평가/요약</label>
|
||||
<textarea value={editRest.evaluation} onChange={(e) => setEditRest({ ...editRest, evaluation: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" rows={2} />
|
||||
<textarea value={editRest.evaluation} onChange={(e) => setEditRest({ ...editRest, evaluation: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" rows={2} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">게스트 (쉼표 구분)</label>
|
||||
<input value={editRest.guests} onChange={(e) => setEditRest({ ...editRest, guests: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" />
|
||||
<input value={editRest.guests} onChange={(e) => setEditRest({ ...editRest, guests: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -1370,7 +1467,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<select
|
||||
value={transcriptMode}
|
||||
onChange={(e) => setTranscriptMode(e.target.value as "auto" | "manual" | "generated")}
|
||||
className="border rounded px-2 py-1 text-xs"
|
||||
className="border rounded px-2 py-1 text-xs bg-white text-gray-900"
|
||||
>
|
||||
<option value="auto">자동 (수동→자동생성)</option>
|
||||
<option value="manual">수동 자막만</option>
|
||||
@@ -1423,6 +1520,12 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
const [editForm, setEditForm] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [videos, setVideos] = useState<VideoLink[]>([]);
|
||||
const [tablingSearching, setTablingSearching] = useState(false);
|
||||
const [bulkTabling, setBulkTabling] = useState(false);
|
||||
const [bulkTablingProgress, setBulkTablingProgress] = useState({ current: 0, total: 0, name: "", linked: 0, notFound: 0 });
|
||||
const [catchtableSearching, setCatchtableSearching] = useState(false);
|
||||
const [bulkCatchtable, setBulkCatchtable] = useState(false);
|
||||
const [bulkCatchtableProgress, setBulkCatchtableProgress] = useState({ current: 0, total: 0, name: "", linked: 0, notFound: 0 });
|
||||
type RestSortKey = "name" | "region" | "cuisine_type" | "price_range" | "rating" | "business_status";
|
||||
const [sortKey, setSortKey] = useState<RestSortKey>("name");
|
||||
const [sortAsc, setSortAsc] = useState(true);
|
||||
@@ -1526,7 +1629,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
value={nameSearch}
|
||||
onChange={(e) => { setNameSearch(e.target.value); setPage(0); }}
|
||||
onKeyDown={(e) => e.key === "Escape" && setNameSearch("")}
|
||||
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48"
|
||||
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-white text-gray-900"
|
||||
/>
|
||||
{nameSearch ? (
|
||||
<button
|
||||
@@ -1545,14 +1648,130 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (<>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const pending = await fetch(`/api/restaurants/tabling-pending`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("tasteby_token")}` },
|
||||
}).then(r => r.json());
|
||||
if (pending.count === 0) { alert("테이블링 미연결 식당이 없습니다"); return; }
|
||||
if (!confirm(`테이블링 미연결 식당 ${pending.count}개를 벌크 검색합니다.\n식당당 5~15초 소요됩니다. 진행할까요?`)) return;
|
||||
setBulkTabling(true);
|
||||
setBulkTablingProgress({ current: 0, total: pending.count, name: "", linked: 0, notFound: 0 });
|
||||
try {
|
||||
const res = await fetch("/api/restaurants/bulk-tabling", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("tasteby_token")}` },
|
||||
});
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^data:(.+)$/);
|
||||
if (!m) continue;
|
||||
const evt = JSON.parse(m[1]);
|
||||
if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
|
||||
setBulkTablingProgress(p => ({
|
||||
...p, current: evt.current, total: evt.total || p.total, name: evt.name,
|
||||
linked: evt.type === "done" ? p.linked + 1 : p.linked,
|
||||
notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
|
||||
}));
|
||||
} else if (evt.type === "complete") {
|
||||
alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}개`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { alert("벌크 테이블링 실패: " + (e instanceof Error ? e.message : String(e))); }
|
||||
finally { setBulkTabling(false); load(); }
|
||||
}}
|
||||
disabled={bulkTabling}
|
||||
className="px-3 py-1.5 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50"
|
||||
>
|
||||
{bulkTabling ? `테이블링 검색 중 (${bulkTablingProgress.current}/${bulkTablingProgress.total})` : "벌크 테이블링 연결"}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const pending = await fetch(`/api/restaurants/catchtable-pending`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("tasteby_token")}` },
|
||||
}).then(r => r.json());
|
||||
if (pending.count === 0) { alert("캐치테이블 미연결 식당이 없습니다"); return; }
|
||||
if (!confirm(`캐치테이블 미연결 식당 ${pending.count}개를 벌크 검색합니다.\n식당당 5~15초 소요됩니다. 진행할까요?`)) return;
|
||||
setBulkCatchtable(true);
|
||||
setBulkCatchtableProgress({ current: 0, total: pending.count, name: "", linked: 0, notFound: 0 });
|
||||
try {
|
||||
const res = await fetch("/api/restaurants/bulk-catchtable", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("tasteby_token")}` },
|
||||
});
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^data:(.+)$/);
|
||||
if (!m) continue;
|
||||
const evt = JSON.parse(m[1]);
|
||||
if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
|
||||
setBulkCatchtableProgress(p => ({
|
||||
...p, current: evt.current, total: evt.total || p.total, name: evt.name,
|
||||
linked: evt.type === "done" ? p.linked + 1 : p.linked,
|
||||
notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
|
||||
}));
|
||||
} else if (evt.type === "complete") {
|
||||
alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}개`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { alert("벌크 캐치테이블 실패: " + (e instanceof Error ? e.message : String(e))); }
|
||||
finally { setBulkCatchtable(false); load(); }
|
||||
}}
|
||||
disabled={bulkCatchtable}
|
||||
className="px-3 py-1.5 text-xs bg-violet-500 text-white rounded hover:bg-violet-600 disabled:opacity-50"
|
||||
>
|
||||
{bulkCatchtable ? `캐치테이블 검색 중 (${bulkCatchtableProgress.current}/${bulkCatchtableProgress.total})` : "벌크 캐치테이블 연결"}
|
||||
</button>
|
||||
</>)}
|
||||
<span className="text-sm text-gray-400 ml-auto">
|
||||
{nameSearch ? `${sorted.length} / ` : ""}총 {restaurants.length}개 식당
|
||||
</span>
|
||||
</div>
|
||||
{bulkTabling && bulkTablingProgress.name && (
|
||||
<div className="bg-orange-50 rounded p-3 mb-4 text-sm">
|
||||
<div className="flex justify-between mb-1">
|
||||
<span>{bulkTablingProgress.current}/{bulkTablingProgress.total} - {bulkTablingProgress.name}</span>
|
||||
<span className="text-xs text-gray-500">연결: {bulkTablingProgress.linked} / 미발견: {bulkTablingProgress.notFound}</span>
|
||||
</div>
|
||||
<div className="w-full bg-orange-200 rounded-full h-1.5">
|
||||
<div className="bg-orange-500 h-1.5 rounded-full transition-all" style={{ width: `${(bulkTablingProgress.current / bulkTablingProgress.total) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{bulkCatchtable && bulkCatchtableProgress.name && (
|
||||
<div className="bg-violet-50 rounded p-3 mb-4 text-sm">
|
||||
<div className="flex justify-between mb-1">
|
||||
<span>{bulkCatchtableProgress.current}/{bulkCatchtableProgress.total} - {bulkCatchtableProgress.name}</span>
|
||||
<span className="text-xs text-gray-500">연결: {bulkCatchtableProgress.linked} / 미발견: {bulkCatchtableProgress.notFound}</span>
|
||||
</div>
|
||||
<div className="w-full bg-violet-200 rounded-full h-1.5">
|
||||
<div className="bg-violet-500 h-1.5 rounded-full transition-all" style={{ width: `${(bulkCatchtableProgress.current / bulkCatchtableProgress.total) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("name")}>이름{sortIcon("name")}</th>
|
||||
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("region")}>지역{sortIcon("region")}</th>
|
||||
@@ -1634,7 +1853,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<input
|
||||
value={editForm[key] || ""}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, [key]: e.target.value }))}
|
||||
className="w-full border rounded px-2 py-1.5 text-sm"
|
||||
className="w-full border rounded px-2 py-1.5 text-sm bg-white text-gray-900"
|
||||
disabled={!isAdmin}
|
||||
/>
|
||||
</div>
|
||||
@@ -1658,6 +1877,109 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{/* 테이블링 연결 */}
|
||||
{isAdmin && (
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="text-xs font-semibold text-gray-500">테이블링</h4>
|
||||
{selected.tabling_url === "NONE" ? (
|
||||
<span className="text-xs text-gray-400">검색완료-없음</span>
|
||||
) : selected.tabling_url ? (
|
||||
<a href={selected.tabling_url} target="_blank" rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-xs">{selected.tabling_url}</a>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">미연결</span>
|
||||
)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
setTablingSearching(true);
|
||||
try {
|
||||
const results = await api.searchTabling(selected.id);
|
||||
if (results.length === 0) {
|
||||
alert("테이블링에서 검색 결과가 없습니다");
|
||||
} else {
|
||||
const best = results[0];
|
||||
if (confirm(`"${best.title}"\n${best.url}\n\n이 테이블링 페이지를 연결할까요?`)) {
|
||||
await api.setTablingUrl(selected.id, best.url);
|
||||
setSelected({ ...selected, tabling_url: best.url });
|
||||
load();
|
||||
}
|
||||
}
|
||||
} catch (e) { alert("검색 실패: " + (e instanceof Error ? e.message : String(e))); }
|
||||
finally { setTablingSearching(false); }
|
||||
}}
|
||||
disabled={tablingSearching}
|
||||
className="px-2 py-0.5 text-[11px] bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50"
|
||||
>
|
||||
{tablingSearching ? "검색 중..." : "테이블링 검색"}
|
||||
</button>
|
||||
{selected.tabling_url && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await api.setTablingUrl(selected.id, "");
|
||||
setSelected({ ...selected, tabling_url: null });
|
||||
load();
|
||||
}}
|
||||
className="px-2 py-0.5 text-[11px] text-red-500 border border-red-200 rounded hover:bg-red-50"
|
||||
>
|
||||
연결 해제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 캐치테이블 연결 */}
|
||||
{isAdmin && (
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="text-xs font-semibold text-gray-500">캐치테이블</h4>
|
||||
{selected.catchtable_url === "NONE" ? (
|
||||
<span className="text-xs text-gray-400">검색완료-없음</span>
|
||||
) : selected.catchtable_url ? (
|
||||
<a href={selected.catchtable_url} target="_blank" rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-xs">{selected.catchtable_url}</a>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">미연결</span>
|
||||
)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
setCatchtableSearching(true);
|
||||
try {
|
||||
const results = await api.searchCatchtable(selected.id);
|
||||
if (results.length === 0) {
|
||||
alert("캐치테이블에서 검색 결과가 없습니다");
|
||||
} else {
|
||||
const best = results[0];
|
||||
if (confirm(`"${best.title}"\n${best.url}\n\n이 캐치테이블 페이지를 연결할까요?`)) {
|
||||
await api.setCatchtableUrl(selected.id, best.url);
|
||||
setSelected({ ...selected, catchtable_url: best.url });
|
||||
load();
|
||||
}
|
||||
}
|
||||
} catch (e) { alert("검색 실패: " + (e instanceof Error ? e.message : String(e))); }
|
||||
finally { setCatchtableSearching(false); }
|
||||
}}
|
||||
disabled={catchtableSearching}
|
||||
className="px-2 py-0.5 text-[11px] bg-violet-500 text-white rounded hover:bg-violet-600 disabled:opacity-50"
|
||||
>
|
||||
{catchtableSearching ? "검색 중..." : "캐치테이블 검색"}
|
||||
</button>
|
||||
{selected.catchtable_url && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await api.setCatchtableUrl(selected.id, "");
|
||||
setSelected({ ...selected, catchtable_url: null });
|
||||
load();
|
||||
}}
|
||||
className="px-2 py-0.5 text-[11px] text-red-500 border border-red-200 rounded hover:bg-red-50"
|
||||
>
|
||||
연결 해제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{videos.length > 0 && (
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<h4 className="text-xs font-semibold text-gray-500 mb-2">연결된 영상 ({videos.length})</h4>
|
||||
@@ -1794,7 +2116,7 @@ function UsersPanel() {
|
||||
{/* Users Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">사용자</th>
|
||||
<th className="text-left px-4 py-2">이메일</th>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
color-scheme: light only;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -14,16 +14,11 @@
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
color-scheme: light only;
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
color-scheme: light only;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
@@ -34,11 +29,7 @@ html, body, #__next {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* Force Google Maps InfoWindow to light mode */
|
||||
/* Force Google Maps InfoWindow to light mode (maps don't support dark) */
|
||||
.gm-style .gm-style-iw,
|
||||
.gm-style .gm-style-iw-c,
|
||||
.gm-style .gm-style-iw-d,
|
||||
@@ -51,3 +42,8 @@ input, select, textarea {
|
||||
.gm-style .gm-style-iw-d {
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
/* Safe area for iOS bottom nav */
|
||||
.safe-area-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ko" style={{ colorScheme: "light" }}>
|
||||
<html lang="ko" className="dark:bg-gray-950" suppressHydrationWarning>
|
||||
<body className={`${geist.variable} font-sans antialiased`}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { GoogleLogin } from "@react-oauth/google";
|
||||
import LoginMenu from "@/components/LoginMenu";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Restaurant, Channel, Review } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
@@ -11,21 +12,22 @@ 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: "한식" },
|
||||
{ label: "일식", prefix: "일식" },
|
||||
{ label: "중식", prefix: "중식" },
|
||||
{ label: "양식", prefix: "양식" },
|
||||
{ label: "아시아", prefix: "아시아" },
|
||||
{ label: "기타", prefix: "기타" },
|
||||
const CUISINE_TAXONOMY: { category: string; items: string[] }[] = [
|
||||
{ category: "한식", items: ["백반/한정식", "국밥/해장국", "찌개/전골/탕", "삼겹살/돼지구이", "소고기/한우구이", "곱창/막창", "닭/오리구이", "족발/보쌈", "회/횟집", "해산물", "분식", "면", "죽/죽집", "순대/순대국", "장어/민물", "주점/포차", "파인다이닝/코스"] },
|
||||
{ category: "일식", items: ["스시/오마카세", "라멘", "돈카츠", "텐동/튀김", "이자카야", "야키니쿠", "카레", "소바/우동", "파인다이닝/코스"] },
|
||||
{ category: "중식", items: ["중화요리", "마라/훠궈", "딤섬/만두", "양꼬치", "파인다이닝/코스"] },
|
||||
{ category: "양식", items: ["파스타/이탈리안", "스테이크", "햄버거", "피자", "프렌치", "바베큐", "브런치", "비건/샐러드", "파인다이닝/코스"] },
|
||||
{ category: "아시아", items: ["베트남", "태국", "인도/중동", "동남아기타"] },
|
||||
{ category: "기타", items: ["치킨", "카페/디저트", "베이커리", "뷔페", "퓨전"] },
|
||||
];
|
||||
|
||||
function matchCuisineGroup(cuisineType: string | null, group: string): boolean {
|
||||
if (!cuisineType) return false;
|
||||
const g = CUISINE_GROUPS.find((g) => g.label === group);
|
||||
if (!g) return false;
|
||||
return cuisineType.startsWith(g.prefix);
|
||||
function matchCuisineFilter(cuisineType: string | null, filter: string): boolean {
|
||||
if (!cuisineType || !filter) return false;
|
||||
// filter can be a category ("한식") or full type ("한식|백반/한정식")
|
||||
if (filter.includes("|")) return cuisineType === filter;
|
||||
return cuisineType.startsWith(filter);
|
||||
}
|
||||
|
||||
const PRICE_GROUPS: { label: string; test: (p: string) => boolean }[] = [
|
||||
@@ -139,6 +141,7 @@ export default function Home() {
|
||||
const [cuisineFilter, setCuisineFilter] = useState("");
|
||||
const [priceFilter, setPriceFilter] = useState("");
|
||||
const [viewMode, setViewMode] = useState<"map" | "list">("list");
|
||||
const [mobileTab, setMobileTab] = useState<"home" | "list" | "nearby" | "favorites" | "profile">("home");
|
||||
const [showMobileFilters, setShowMobileFilters] = useState(false);
|
||||
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
||||
const [boundsFilterOn, setBoundsFilterOn] = useState(false);
|
||||
@@ -150,6 +153,9 @@ export default function Home() {
|
||||
const [showMyReviews, setShowMyReviews] = useState(false);
|
||||
const [myReviews, setMyReviews] = useState<(Review & { restaurant_id: string; restaurant_name: string | null })[]>([]);
|
||||
const [visits, setVisits] = useState<{ today: number; total: number } | null>(null);
|
||||
const [userLoc, setUserLoc] = useState<{ lat: number; lng: number }>({ lat: 37.498, lng: 127.0276 });
|
||||
const [isSearchResult, setIsSearchResult] = useState(false);
|
||||
const [resetCount, setResetCount] = useState(0);
|
||||
const geoApplied = useRef(false);
|
||||
|
||||
const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]);
|
||||
@@ -168,9 +174,18 @@ export default function Home() {
|
||||
}, [regionTree, countryFilter, cityFilter]);
|
||||
|
||||
const filteredRestaurants = useMemo(() => {
|
||||
const dist = (r: Restaurant) =>
|
||||
(r.latitude - userLoc.lat) ** 2 + (r.longitude - userLoc.lng) ** 2;
|
||||
if (isSearchResult) {
|
||||
return [...restaurants].sort((a, b) => {
|
||||
const da = dist(a), db = dist(b);
|
||||
if (da !== db) return da - db;
|
||||
return (b.rating || 0) - (a.rating || 0);
|
||||
});
|
||||
}
|
||||
return restaurants.filter((r) => {
|
||||
if (channelFilter && !(r.channels || []).includes(channelFilter)) return false;
|
||||
if (cuisineFilter && !matchCuisineGroup(r.cuisine_type, cuisineFilter)) return false;
|
||||
if (cuisineFilter && !matchCuisineFilter(r.cuisine_type, cuisineFilter)) return false;
|
||||
if (priceFilter && !matchPriceGroup(r.price_range, priceFilter)) return false;
|
||||
if (countryFilter) {
|
||||
const parsed = parseRegion(r.region);
|
||||
@@ -183,12 +198,23 @@ export default function Home() {
|
||||
if (r.longitude < mapBounds.west || r.longitude > mapBounds.east) return false;
|
||||
}
|
||||
return true;
|
||||
}).sort((a, b) => {
|
||||
const da = dist(a), db = dist(b);
|
||||
if (da !== db) return da - db;
|
||||
return (b.rating || 0) - (a.rating || 0);
|
||||
});
|
||||
}, [restaurants, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds]);
|
||||
}, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
|
||||
|
||||
// Set desktop default to map mode on mount
|
||||
// Set desktop default to map mode on mount + get user location
|
||||
useEffect(() => {
|
||||
if (window.innerWidth >= 768) setViewMode("map");
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
|
||||
() => {},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load channels + record visit on mount
|
||||
@@ -200,6 +226,7 @@ export default function Home() {
|
||||
// Load restaurants on mount and when channel filter changes
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setIsSearchResult(false);
|
||||
api
|
||||
.getRestaurants({ limit: 500, channel: channelFilter || undefined })
|
||||
.then(setRestaurants)
|
||||
@@ -219,12 +246,9 @@ export default function Home() {
|
||||
if (match) {
|
||||
setCountryFilter(match.country);
|
||||
setCityFilter(match.city);
|
||||
const matched = restaurants.filter((r) => {
|
||||
const p = parseRegion(r.region);
|
||||
return p && p.country === match.country && p.city === match.city;
|
||||
});
|
||||
setRegionFlyTo(computeFlyTo(matched));
|
||||
}
|
||||
const mobile = window.innerWidth < 768;
|
||||
setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: mobile ? 13 : 16 });
|
||||
},
|
||||
() => { /* user denied or error — do nothing */ },
|
||||
{ timeout: 5000 },
|
||||
@@ -239,6 +263,18 @@ export default function Home() {
|
||||
setRestaurants(results);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
setIsSearchResult(true);
|
||||
// 검색 시 필터 초기화
|
||||
setChannelFilter("");
|
||||
setCuisineFilter("");
|
||||
setPriceFilter("");
|
||||
setCountryFilter("");
|
||||
setCityFilter("");
|
||||
setDistrictFilter("");
|
||||
setBoundsFilterOn(false);
|
||||
// 검색 결과에 맞게 지도 이동
|
||||
const flyTo = computeFlyTo(results);
|
||||
if (flyTo) setRegionFlyTo(flyTo);
|
||||
} catch (e) {
|
||||
console.error("Search failed:", e);
|
||||
} finally {
|
||||
@@ -261,6 +297,21 @@ export default function Home() {
|
||||
setMapBounds(bounds);
|
||||
}, []);
|
||||
|
||||
const handleMyLocation = useCallback(() => {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude });
|
||||
setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 16 });
|
||||
},
|
||||
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 16 }),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
} else {
|
||||
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 16 });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCountryChange = useCallback((country: string) => {
|
||||
setCountryFilter(country);
|
||||
setCityFilter("");
|
||||
@@ -321,6 +372,8 @@ export default function Home() {
|
||||
setBoundsFilterOn(false);
|
||||
setShowFavorites(false);
|
||||
setShowMyReviews(false);
|
||||
setIsSearchResult(false);
|
||||
setResetCount((c) => c + 1);
|
||||
api
|
||||
.getRestaurants({ limit: 500 })
|
||||
.then((data) => {
|
||||
@@ -332,6 +385,75 @@ export default function Home() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleMobileTab = useCallback(async (tab: "home" | "list" | "nearby" | "favorites" | "profile") => {
|
||||
// 홈 탭 재클릭 = 리셋
|
||||
if (tab === "home" && mobileTab === "home") {
|
||||
handleReset();
|
||||
return;
|
||||
}
|
||||
|
||||
setMobileTab(tab);
|
||||
setShowDetail(false);
|
||||
setShowMobileFilters(false);
|
||||
setSelected(null);
|
||||
|
||||
if (tab === "nearby") {
|
||||
setBoundsFilterOn(true);
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 13 }),
|
||||
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 13 }),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
} else {
|
||||
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 13 });
|
||||
}
|
||||
// 내주변에서 돌아올 때를 위해 favorites/reviews 해제
|
||||
if (showFavorites || showMyReviews) {
|
||||
setShowFavorites(false);
|
||||
setShowMyReviews(false);
|
||||
setMyReviews([]);
|
||||
api.getRestaurants({ limit: 500, channel: channelFilter || undefined }).then(setRestaurants);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setBoundsFilterOn(false);
|
||||
|
||||
if (tab === "favorites") {
|
||||
if (!user) return;
|
||||
setShowMyReviews(false);
|
||||
setMyReviews([]);
|
||||
try {
|
||||
const favs = await api.getMyFavorites();
|
||||
setRestaurants(favs);
|
||||
setShowFavorites(true);
|
||||
} catch { /* ignore */ }
|
||||
} else if (tab === "profile") {
|
||||
if (!user) return;
|
||||
setShowFavorites(false);
|
||||
try {
|
||||
const reviews = await api.getMyReviews();
|
||||
setMyReviews(reviews);
|
||||
setShowMyReviews(true);
|
||||
} catch { /* ignore */ }
|
||||
// 프로필에서는 식당 목록을 원래대로 복원
|
||||
if (showFavorites) {
|
||||
api.getRestaurants({ limit: 500, channel: channelFilter || undefined }).then(setRestaurants);
|
||||
}
|
||||
} else {
|
||||
// 홈 / 식당 목록 - 항상 전체 식당 목록으로 복원
|
||||
const needReload = showFavorites || showMyReviews;
|
||||
setShowFavorites(false);
|
||||
setShowMyReviews(false);
|
||||
setMyReviews([]);
|
||||
if (needReload) {
|
||||
const data = await api.getRestaurants({ limit: 500, channel: channelFilter || undefined });
|
||||
setRestaurants(data);
|
||||
}
|
||||
}
|
||||
}, [user, showFavorites, showMyReviews, channelFilter, mobileTab, handleReset]);
|
||||
|
||||
const handleToggleFavorites = async () => {
|
||||
if (showFavorites) {
|
||||
setShowFavorites(false);
|
||||
@@ -391,6 +513,7 @@ export default function Home() {
|
||||
selectedId={selected?.id}
|
||||
onSelect={handleSelectRestaurant}
|
||||
loading={loading}
|
||||
keyPrefix="d-"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -414,25 +537,33 @@ export default function Home() {
|
||||
selectedId={selected?.id}
|
||||
onSelect={handleSelectRestaurant}
|
||||
loading={loading}
|
||||
keyPrefix="m-"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<div className="h-screen flex flex-col bg-white dark:bg-gray-950">
|
||||
{/* ── Header row 1: Logo + User ── */}
|
||||
<header className="bg-white border-b shrink-0">
|
||||
<div className="px-4 py-2 flex items-center justify-between">
|
||||
<header className="bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b dark:border-gray-800 shrink-0">
|
||||
<div className="px-5 py-3 flex items-center justify-between">
|
||||
<button onClick={handleReset} className="text-lg font-bold whitespace-nowrap">
|
||||
Tasteby
|
||||
</button>
|
||||
|
||||
{/* Desktop: search + filters — two rows */}
|
||||
<div className="hidden md:flex flex-col gap-1.5 mx-4">
|
||||
<div className="hidden md:flex flex-col gap-2.5 mx-6">
|
||||
{/* Row 1: Search + dropdown filters */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-96 shrink-0">
|
||||
<SearchBar onSearch={handleSearch} isLoading={loading} />
|
||||
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="p-1.5 text-gray-400 dark:text-gray-500 hover:text-orange-500 dark:hover:text-orange-400 transition-colors"
|
||||
title="초기화"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
</button>
|
||||
<select
|
||||
value={channelFilter}
|
||||
onChange={(e) => {
|
||||
@@ -440,54 +571,64 @@ export default function Home() {
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
}}
|
||||
className="border rounded px-2 py-1 text-sm text-gray-600"
|
||||
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">전체 채널</option>
|
||||
<option value="">📺 채널</option>
|
||||
{channels.map((ch) => (
|
||||
<option key={ch.id} value={ch.channel_name}>
|
||||
{ch.channel_name}
|
||||
📺 {ch.channel_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={cuisineFilter}
|
||||
onChange={(e) => setCuisineFilter(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm text-gray-600"
|
||||
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">전체 장르</option>
|
||||
{CUISINE_GROUPS.map((g) => (
|
||||
<option key={g.label} value={g.label}>{g.label}</option>
|
||||
<option value="">🍽 장르</option>
|
||||
{CUISINE_TAXONOMY.map((g) => (
|
||||
<optgroup key={g.category} label={`── ${g.category} ──`}>
|
||||
<option value={g.category}>🍽 {g.category} 전체</option>
|
||||
{g.items.map((item) => (
|
||||
<option key={`${g.category}|${item}`} value={`${g.category}|${item}`}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={priceFilter}
|
||||
onChange={(e) => setPriceFilter(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm text-gray-600"
|
||||
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">전체 가격</option>
|
||||
<option value="">💰 가격</option>
|
||||
{PRICE_GROUPS.map((g) => (
|
||||
<option key={g.label} value={g.label}>{g.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Row 2: Region filters + Toggle buttons + count */}
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={countryFilter}
|
||||
onChange={(e) => handleCountryChange(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm text-gray-600"
|
||||
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">전체 나라</option>
|
||||
<option value="">🌍 나라</option>
|
||||
{countries.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
<option key={c} value={c}>🌍 {c}</option>
|
||||
))}
|
||||
</select>
|
||||
{countryFilter && cities.length > 0 && (
|
||||
<select
|
||||
value={cityFilter}
|
||||
onChange={(e) => handleCityChange(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm text-gray-600"
|
||||
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">전체 시/도</option>
|
||||
<option value="">🏙 전체 시/도</option>
|
||||
{cities.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
<option key={c} value={c}>🏙 {c}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
@@ -495,53 +636,65 @@ export default function Home() {
|
||||
<select
|
||||
value={districtFilter}
|
||||
onChange={(e) => handleDistrictChange(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm text-gray-600"
|
||||
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">전체 구/군</option>
|
||||
<option value="">🏘 전체 구/군</option>
|
||||
{districts.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
<option key={d} value={d}>🏘 {d}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{/* Row 2: Toggle buttons + count */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-px h-5 bg-gray-200 dark:bg-gray-700" />
|
||||
<button
|
||||
onClick={() => setBoundsFilterOn(!boundsFilterOn)}
|
||||
className={`px-2.5 py-1 text-sm border rounded transition-colors ${
|
||||
onClick={() => {
|
||||
const next = !boundsFilterOn;
|
||||
setBoundsFilterOn(next);
|
||||
if (next) {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }),
|
||||
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
} else {
|
||||
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 });
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-1.5 text-sm border rounded-lg transition-colors ${
|
||||
boundsFilterOn
|
||||
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||
: "hover:bg-gray-100 text-gray-600"
|
||||
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400"
|
||||
: "hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||||
}`}
|
||||
title="지도 영역 내 식당만 표시"
|
||||
title="내 위치 주변 식당만 표시"
|
||||
>
|
||||
{boundsFilterOn ? "📍 영역" : "📍"}
|
||||
{boundsFilterOn ? "📍 내위치 ON" : "📍 내위치"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === "map" ? "list" : "map")}
|
||||
className="px-2.5 py-1 text-sm border rounded transition-colors hover:bg-gray-100 text-gray-600"
|
||||
className="px-3 py-1.5 text-sm border rounded-lg transition-colors hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||||
title={viewMode === "map" ? "리스트 우선" : "지도 우선"}
|
||||
>
|
||||
{viewMode === "map" ? "🗺" : "☰"}
|
||||
{viewMode === "map" ? "🗺 지도" : "☰ 리스트"}
|
||||
</button>
|
||||
{user && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleToggleFavorites}
|
||||
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
|
||||
className={`px-3.5 py-1.5 text-sm rounded-full border transition-colors ${
|
||||
showFavorites
|
||||
? "bg-red-50 border-red-300 text-red-600"
|
||||
: "border-gray-300 text-gray-600 hover:bg-gray-100"
|
||||
? "bg-rose-50 dark:bg-rose-900/30 border-rose-300 dark:border-rose-700 text-rose-600 dark:text-rose-400"
|
||||
: "border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{showFavorites ? "♥ 내 찜" : "♡ 찜"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleMyReviews}
|
||||
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
|
||||
className={`px-3.5 py-1.5 text-sm rounded-full border transition-colors ${
|
||||
showMyReviews
|
||||
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||
: "border-gray-300 text-gray-600 hover:bg-gray-100"
|
||||
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400"
|
||||
: "border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{showMyReviews ? "✎ 내 리뷰" : "✎ 리뷰"}
|
||||
@@ -565,89 +718,47 @@ export default function Home() {
|
||||
className="w-8 h-8 rounded-full border border-gray-200"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-amber-100 text-amber-700 flex items-center justify-center text-sm font-semibold border border-amber-200">
|
||||
<div className="w-8 h-8 rounded-full bg-orange-100 text-orange-700 flex items-center justify-center text-sm font-semibold border border-orange-200">
|
||||
{(user.nickname || user.email || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="hidden sm:inline text-sm font-medium text-gray-700">
|
||||
<span className="hidden sm:inline text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{user.nickname || user.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="ml-1 px-2.5 py-1 text-xs text-gray-500 border border-gray-300 rounded-full hover:bg-gray-100 hover:text-gray-700 transition-colors"
|
||||
className="ml-1 px-2.5 py-1 text-xs text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<GoogleLogin
|
||||
onSuccess={(credentialResponse) => {
|
||||
if (credentialResponse.credential) {
|
||||
login(credentialResponse.credential).catch(console.error);
|
||||
}
|
||||
}}
|
||||
onError={() => console.error("Google login failed")}
|
||||
size="small"
|
||||
/>
|
||||
<LoginMenu onGoogleSuccess={(credential) => login(credential).catch(console.error)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Header row 2 (mobile only): search + toolbar ── */}
|
||||
<div className="md:hidden px-4 pb-2 space-y-1.5">
|
||||
<div className={`md:hidden px-4 pb-3 space-y-2 ${mobileTab === "favorites" || mobileTab === "profile" ? "hidden" : ""}`}>
|
||||
{/* Row 1: Search */}
|
||||
<SearchBar onSearch={handleSearch} isLoading={loading} />
|
||||
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
|
||||
{/* Row 2: Toolbar */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === "map" ? "list" : "map")}
|
||||
className={`px-2.5 py-1 text-xs border rounded transition-colors ${
|
||||
viewMode === "map"
|
||||
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||
: "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{viewMode === "map" ? "🗺 지도" : "☰ 리스트"}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowMobileFilters(!showMobileFilters)}
|
||||
className={`px-2.5 py-1 text-xs border rounded transition-colors relative ${
|
||||
showMobileFilters || channelFilter || cuisineFilter || priceFilter || countryFilter || boundsFilterOn
|
||||
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||
className={`px-3 py-1.5 text-xs border rounded-lg transition-colors relative ${
|
||||
showMobileFilters || channelFilter || cuisineFilter || priceFilter || countryFilter
|
||||
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400"
|
||||
: "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{showMobileFilters ? "✕ 필터" : "▽ 필터"}
|
||||
{!showMobileFilters && (channelFilter || cuisineFilter || priceFilter || countryFilter || boundsFilterOn) && (
|
||||
<span className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-blue-500 text-white rounded-full text-[9px] flex items-center justify-center">
|
||||
{[channelFilter, cuisineFilter, priceFilter, countryFilter, boundsFilterOn].filter(Boolean).length}
|
||||
{showMobileFilters ? "✕ 닫기" : "🔽 필터"}
|
||||
{!showMobileFilters && (channelFilter || cuisineFilter || priceFilter || countryFilter) && (
|
||||
<span className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-orange-500 text-white rounded-full text-[9px] flex items-center justify-center">
|
||||
{[channelFilter, cuisineFilter, priceFilter, countryFilter].filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{user && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleToggleFavorites}
|
||||
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
|
||||
showFavorites
|
||||
? "bg-red-50 border-red-300 text-red-600"
|
||||
: "border-gray-300 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{showFavorites ? "♥ 찜" : "♡ 찜"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleMyReviews}
|
||||
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
|
||||
showMyReviews
|
||||
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||
: "border-gray-300 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{showMyReviews ? "✎ 리뷰" : "✎ 리뷰"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<span className="text-xs text-gray-400 ml-auto">
|
||||
{filteredRestaurants.length}개
|
||||
</span>
|
||||
@@ -655,9 +766,9 @@ export default function Home() {
|
||||
|
||||
{/* Collapsible filter panel */}
|
||||
{showMobileFilters && (
|
||||
<div className="bg-gray-50 rounded-lg p-3 space-y-2 border">
|
||||
<div className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-md rounded-xl p-3.5 space-y-3 border border-white/50 dark:border-gray-700/50 shadow-sm">
|
||||
{/* Dropdown filters */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
value={channelFilter}
|
||||
onChange={(e) => {
|
||||
@@ -665,57 +776,64 @@ export default function Home() {
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
}}
|
||||
className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
|
||||
className="border dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="">전체 채널</option>
|
||||
<option value="">📺 채널</option>
|
||||
{channels.map((ch) => (
|
||||
<option key={ch.id} value={ch.channel_name}>
|
||||
{ch.channel_name}
|
||||
📺 {ch.channel_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={cuisineFilter}
|
||||
onChange={(e) => setCuisineFilter(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
|
||||
className="border dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="">전체 장르</option>
|
||||
{CUISINE_GROUPS.map((g) => (
|
||||
<option key={g.label} value={g.label}>{g.label}</option>
|
||||
<option value="">🍽 장르</option>
|
||||
{CUISINE_TAXONOMY.map((g) => (
|
||||
<optgroup key={g.category} label={`── ${g.category} ──`}>
|
||||
<option value={g.category}>🍽 {g.category} 전체</option>
|
||||
{g.items.map((item) => (
|
||||
<option key={`${g.category}|${item}`} value={`${g.category}|${item}`}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={priceFilter}
|
||||
onChange={(e) => setPriceFilter(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
|
||||
className="border dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="">전체 가격</option>
|
||||
<option value="">💰 가격</option>
|
||||
{PRICE_GROUPS.map((g) => (
|
||||
<option key={g.label} value={g.label}>{g.label}</option>
|
||||
<option key={g.label} value={g.label}>💰 {g.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Region filters */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
value={countryFilter}
|
||||
onChange={(e) => handleCountryChange(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
|
||||
className="border dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="">전체 나라</option>
|
||||
<option value="">🌍 나라</option>
|
||||
{countries.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
<option key={c} value={c}>🌍 {c}</option>
|
||||
))}
|
||||
</select>
|
||||
{countryFilter && cities.length > 0 && (
|
||||
<select
|
||||
value={cityFilter}
|
||||
onChange={(e) => handleCityChange(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
|
||||
className="border dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="">전체 시/도</option>
|
||||
<option value="">🏙 전체 시/도</option>
|
||||
{cities.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
<option key={c} value={c}>🏙 {c}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
@@ -723,26 +841,40 @@ export default function Home() {
|
||||
<select
|
||||
value={districtFilter}
|
||||
onChange={(e) => handleDistrictChange(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
|
||||
className="border dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="">전체 구/군</option>
|
||||
<option value="">🏘 전체 구/군</option>
|
||||
{districts.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
<option key={d} value={d}>🏘 {d}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{/* Toggle buttons */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setBoundsFilterOn(!boundsFilterOn)}
|
||||
className={`px-2 py-1 text-xs border rounded transition-colors ${
|
||||
onClick={() => {
|
||||
const next = !boundsFilterOn;
|
||||
setBoundsFilterOn(next);
|
||||
if (next) {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }),
|
||||
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
} else {
|
||||
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 });
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`px-2.5 py-1.5 text-xs border rounded-lg transition-colors ${
|
||||
boundsFilterOn
|
||||
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||
: "text-gray-600 bg-white"
|
||||
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400"
|
||||
: "text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
{boundsFilterOn ? "📍 영역 ON" : "📍 영역"}
|
||||
{boundsFilterOn ? "📍 내위치 ON" : "📍 내위치"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -756,7 +888,7 @@ export default function Home() {
|
||||
<div className="hidden md:flex flex-1 overflow-hidden">
|
||||
{viewMode === "map" ? (
|
||||
<>
|
||||
<aside className="w-80 bg-white border-r overflow-y-auto shrink-0">
|
||||
<aside className="w-80 bg-white dark:bg-gray-950 border-r dark:border-gray-800 overflow-y-auto shrink-0">
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
<main className="flex-1 relative">
|
||||
@@ -766,9 +898,11 @@ export default function Home() {
|
||||
onSelectRestaurant={handleSelectRestaurant}
|
||||
onBoundsChanged={handleBoundsChanged}
|
||||
flyTo={regionFlyTo}
|
||||
onMyLocation={handleMyLocation}
|
||||
activeChannel={channelFilter || undefined}
|
||||
/>
|
||||
{visits && (
|
||||
<div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded">
|
||||
<div className="absolute bottom-1 right-2 bg-white/60 backdrop-blur-sm text-gray-700 text-[10px] px-2.5 py-1 rounded-lg shadow-sm">
|
||||
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
@@ -776,19 +910,21 @@ export default function Home() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<aside className="flex-1 bg-white overflow-y-auto">
|
||||
<aside className="flex-1 bg-white dark:bg-gray-950 overflow-y-auto">
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
<main className="w-[40%] shrink-0 relative border-l">
|
||||
<main className="w-[40%] shrink-0 relative border-l dark:border-gray-800">
|
||||
<MapView
|
||||
restaurants={filteredRestaurants}
|
||||
selected={selected}
|
||||
onSelectRestaurant={handleSelectRestaurant}
|
||||
onBoundsChanged={handleBoundsChanged}
|
||||
flyTo={regionFlyTo}
|
||||
onMyLocation={handleMyLocation}
|
||||
activeChannel={channelFilter || undefined}
|
||||
/>
|
||||
{visits && (
|
||||
<div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded">
|
||||
<div className="absolute bottom-1 right-2 bg-white/60 backdrop-blur-sm text-gray-700 text-[10px] px-2.5 py-1 rounded-lg shadow-sm">
|
||||
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
@@ -798,49 +934,115 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{/* Mobile layout */}
|
||||
<div className="md:hidden flex-1 flex flex-col overflow-hidden">
|
||||
{viewMode === "map" ? (
|
||||
<>
|
||||
<div className="flex-1 relative">
|
||||
<div className="md:hidden flex-1 flex flex-col overflow-hidden pb-14">
|
||||
{/* Tab content — takes all remaining space above fixed nav */}
|
||||
{mobileTab === "nearby" ? (
|
||||
/* 내주변: 지도 + 리스트 분할, 영역필터 ON */
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="h-[45%] relative shrink-0">
|
||||
<MapView
|
||||
restaurants={filteredRestaurants}
|
||||
selected={selected}
|
||||
onSelectRestaurant={handleSelectRestaurant}
|
||||
onBoundsChanged={handleBoundsChanged}
|
||||
flyTo={regionFlyTo}
|
||||
onMyLocation={handleMyLocation}
|
||||
activeChannel={channelFilter || undefined}
|
||||
/>
|
||||
<div className="absolute top-2 left-2 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm rounded-lg px-3 py-1.5 shadow-sm z-10">
|
||||
<span className="text-xs font-medium text-orange-600 dark:text-orange-400">
|
||||
내 주변 {filteredRestaurants.length}개
|
||||
</span>
|
||||
</div>
|
||||
{visits && (
|
||||
<div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded z-10">
|
||||
<div className="absolute bottom-1 right-2 bg-white/60 backdrop-blur-sm text-gray-700 text-[10px] px-2.5 py-1 rounded-lg shadow-sm z-10">
|
||||
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 bg-white overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{mobileListContent}
|
||||
{/* Scroll-down hint to reveal map */}
|
||||
<div className="flex flex-col items-center py-4 text-gray-300">
|
||||
<span className="text-lg">▼</span>
|
||||
<span className="text-[10px]">아래로 스크롤하면 지도</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[35vh] shrink-0 relative border-t">
|
||||
<MapView
|
||||
restaurants={filteredRestaurants}
|
||||
selected={selected}
|
||||
onSelectRestaurant={handleSelectRestaurant}
|
||||
onBoundsChanged={handleBoundsChanged}
|
||||
flyTo={regionFlyTo}
|
||||
) : mobileTab === "profile" ? (
|
||||
/* 내정보 */
|
||||
<div className="flex-1 overflow-y-auto bg-white dark:bg-gray-950">
|
||||
{!user ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-6">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">로그인하고 리뷰와 찜 목록을 관리하세요</p>
|
||||
<GoogleLogin
|
||||
onSuccess={(res) => {
|
||||
if (res.credential) login(res.credential).catch(console.error);
|
||||
}}
|
||||
onError={() => console.error("Google login failed")}
|
||||
size="large"
|
||||
text="signin_with"
|
||||
/>
|
||||
{visits && (
|
||||
<div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded z-10">
|
||||
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 프로필 헤더 */}
|
||||
<div className="flex items-center gap-3 pb-4 border-b dark:border-gray-800">
|
||||
{user.avatar_url ? (
|
||||
<img src={user.avatar_url} alt="" className="w-12 h-12 rounded-full border border-gray-200" />
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-orange-100 text-orange-700 flex items-center justify-center text-lg font-semibold border border-orange-200">
|
||||
{(user.nickname || user.email || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-sm dark:text-gray-100">{user.nickname || user.email}</p>
|
||||
{user.nickname && user.email && (
|
||||
<p className="text-xs text-gray-400">{user.email}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="px-3 py-1.5 text-xs text-gray-500 border border-gray-300 dark:border-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
{/* 내 리뷰 */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm mb-2 dark:text-gray-200">내 리뷰</h3>
|
||||
{myReviews.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">작성한 리뷰가 없습니다</p>
|
||||
) : (
|
||||
<MyReviewsList
|
||||
reviews={myReviews}
|
||||
onClose={() => {}}
|
||||
onSelectRestaurant={async (restaurantId) => {
|
||||
try {
|
||||
const r = await api.getRestaurant(restaurantId);
|
||||
handleSelectRestaurant(r);
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* 홈 / 식당 목록 / 찜: 리스트 표시 */
|
||||
<div className="flex-1 overflow-y-auto bg-white dark:bg-gray-950">
|
||||
{mobileTab === "favorites" && !user ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-6">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">로그인하고 찜 목록을 확인하세요</p>
|
||||
<GoogleLogin
|
||||
onSuccess={(res) => {
|
||||
if (res.credential) login(res.credential).catch(console.error);
|
||||
}}
|
||||
onError={() => console.error("Google login failed")}
|
||||
size="large"
|
||||
text="signin_with"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
mobileListContent
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Bottom Sheet for restaurant detail */}
|
||||
@@ -850,6 +1052,58 @@ export default function Home() {
|
||||
)}
|
||||
</BottomSheet>
|
||||
</div>
|
||||
|
||||
{/* ── Mobile Bottom Nav (fixed) ── */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 border-t dark:border-gray-800 bg-white dark:bg-gray-950 safe-area-bottom">
|
||||
<div className="flex items-stretch h-14">
|
||||
{([
|
||||
{ key: "home", label: "홈", icon: (
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M12 3l9 8h-3v9h-5v-6h-2v6H6v-9H3z"/></svg>
|
||||
)},
|
||||
{ key: "list", label: "식당 목록", icon: (
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M3 4h18v2H3zm0 7h18v2H3zm0 7h18v2H3z"/></svg>
|
||||
)},
|
||||
{ key: "nearby", label: "내주변", icon: (
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
|
||||
)},
|
||||
{ key: "favorites", label: "찜", icon: (
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
|
||||
)},
|
||||
{ key: "profile", label: "내정보", icon: (
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
||||
)},
|
||||
] as { key: "home" | "list" | "nearby" | "favorites" | "profile"; label: string; icon: React.ReactNode }[]).map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => handleMobileTab(tab.key)}
|
||||
className={`flex-1 flex flex-col items-center justify-center gap-0.5 py-2 transition-colors ${
|
||||
mobileTab === tab.key
|
||||
? "text-orange-600 dark:text-orange-400"
|
||||
: "text-gray-400 dark:text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
<span className="text-[10px] font-medium">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Desktop Footer */}
|
||||
<footer className="hidden md:flex shrink-0 border-t dark:border-gray-800 bg-white/60 dark:bg-gray-900/60 backdrop-blur-sm py-2.5 items-center justify-center gap-2 text-[11px] text-gray-400 dark:text-gray-500 group">
|
||||
<div className="relative">
|
||||
<img
|
||||
src="/icon.jpg"
|
||||
alt="SDJ Labs"
|
||||
className="w-6 h-6 rounded-full border-2 border-orange-200 shadow-sm group-hover:scale-110 group-hover:rotate-12 transition-all duration-300"
|
||||
/>
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-orange-300 rounded-full animate-ping opacity-75" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-orange-400 rounded-full" />
|
||||
</div>
|
||||
<span className="font-medium tracking-wide group-hover:text-gray-600 transition-colors">
|
||||
SDJ Labs Co., Ltd.
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user