From 610a80862270f890a3115871cd57299058018aaa Mon Sep 17 00:00:00 2001 From: yugyeom <48901587+rladbrua0207@users.noreply.github.com> Date: Wed, 18 Sep 2024 15:08:39 +0900 Subject: [PATCH 01/12] =?UTF-8?q?Init:=20=EA=B0=9C=EB=B0=9C=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EC=84=B8=ED=8C=85=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Init: 프로젝트 기본설정 세팅 - 프로젝트 생성 - .gitignore설정 - 프로젝트 의존성 추가 - application.yml 설정파일 구성 * Init: 프로젝트 기본 구조 및 공통 컴포넌트 설정 - 공통 설정 클래스 추가 (JPA, QueryDSL, Swagger, Web) - 공통 도메인 엔티티 (RootEntity) 정의 - 예외 처리 관련 클래스 및 타입 구현 - JSON 변환을 위한 AttributeConverter 추가 - 유틸리티 클래스 (Math) 추가 --- .gitignore | 200 ++++++++++++++ HELP.md | 27 ++ build.gradle | 60 +++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 252 ++++++++++++++++++ gradlew.bat | 94 +++++++ settings.gradle | 1 + src/main/java/notai/BackendApplication.java | 15 ++ .../java/notai/common/config/JpaConfig.java | 9 + .../notai/common/config/QueryDslConfig.java | 19 ++ .../notai/common/config/SwaggerConfig.java | 51 ++++ .../java/notai/common/config/WebConfig.java | 8 + .../converter/JsonAttributeConverter.java | 39 +++ .../java/notai/common/domain/RootEntity.java | 49 ++++ .../exception/ApplicationException.java | 14 + .../exception/ExceptionControllerAdvice.java | 113 ++++++++ .../common/exception/ExceptionResponse.java | 6 + .../exception/type/BadRequestException.java | 11 + .../exception/type/ConflictException.java | 11 + .../exception/type/ExternalApiException.java | 11 + .../exception/type/ForbiddenException.java | 11 + .../type/InternalServerErrorException.java | 11 + .../type/JsonConversionException.java | 10 + .../exception/type/NotFoundException.java | 11 + .../exception/type/UnAuthorizedException.java | 11 + src/main/java/notai/common/utils/Math.java | 5 + src/main/resources/application-local.yml | 36 +++ src/main/resources/application.yml | 3 + .../java/notai/BackendApplicationTests.java | 13 + 30 files changed, 1108 insertions(+) create mode 100644 .gitignore create mode 100644 HELP.md create mode 100644 build.gradle create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/notai/BackendApplication.java create mode 100644 src/main/java/notai/common/config/JpaConfig.java create mode 100644 src/main/java/notai/common/config/QueryDslConfig.java create mode 100644 src/main/java/notai/common/config/SwaggerConfig.java create mode 100644 src/main/java/notai/common/config/WebConfig.java create mode 100644 src/main/java/notai/common/converter/JsonAttributeConverter.java create mode 100644 src/main/java/notai/common/domain/RootEntity.java create mode 100644 src/main/java/notai/common/exception/ApplicationException.java create mode 100644 src/main/java/notai/common/exception/ExceptionControllerAdvice.java create mode 100644 src/main/java/notai/common/exception/ExceptionResponse.java create mode 100644 src/main/java/notai/common/exception/type/BadRequestException.java create mode 100644 src/main/java/notai/common/exception/type/ConflictException.java create mode 100644 src/main/java/notai/common/exception/type/ExternalApiException.java create mode 100644 src/main/java/notai/common/exception/type/ForbiddenException.java create mode 100644 src/main/java/notai/common/exception/type/InternalServerErrorException.java create mode 100644 src/main/java/notai/common/exception/type/JsonConversionException.java create mode 100644 src/main/java/notai/common/exception/type/NotFoundException.java create mode 100644 src/main/java/notai/common/exception/type/UnAuthorizedException.java create mode 100644 src/main/java/notai/common/utils/Math.java create mode 100644 src/main/resources/application-local.yml create mode 100644 src/main/resources/application.yml create mode 100644 src/test/java/notai/BackendApplicationTests.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7147c1c --- /dev/null +++ b/.gitignore @@ -0,0 +1,200 @@ +# Created by https://www.toptal.com/developers/gitignore/api/macos,intellij+all,gradle,java,git +# Edit at https://www.toptal.com/developers/gitignore?templates=macos,intellij+all,gradle,java,git + +### Git ### +# Created by git for backups. To disable backups in Git: +# $ git config --global mergetool.keepBackup false +*.orig + +# Created by git when using merge tools for conflicts +*.BACKUP.* +*.BASE.* +*.LOCAL.* +*.REMOTE.* +*_BACKUP_*.txt +*_BASE_*.txt +*_LOCAL_*.txt +*_REMOTE_*.txt + +### Intellij+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. + +.idea/* + +!.idea/codeStyles +!.idea/runConfigurations + +### Java ### +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Gradle ### +.gradle +**/build/ +!src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +### Gradle Patch ### +# Java heap dump +*.hprof + +# End of https://www.toptal.com/developers/gitignore/api/macos,intellij+all,gradle,java,git + + +/src/main/generated/ +/src/main/resources/** +!/src/main/resources/application.yml +!/src/main/resources/application-local.yml +!/src/main/resources/logback-spring.xml diff --git a/HELP.md b/HELP.md new file mode 100644 index 0000000..d24cf3b --- /dev/null +++ b/HELP.md @@ -0,0 +1,27 @@ +# Getting Started + +### Reference Documentation +For further reference, please consider the following sections: + +* [Official Gradle documentation](https://docs.gradle.org) +* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/3.3.3/gradle-plugin) +* [Create an OCI image](https://docs.spring.io/spring-boot/3.3.3/gradle-plugin/packaging-oci-image.html) +* [Spring Web](https://docs.spring.io/spring-boot/docs/3.3.3/reference/htmlsingle/index.html#web) +* [Spring Data JPA](https://docs.spring.io/spring-boot/docs/3.3.3/reference/htmlsingle/index.html#data.sql.jpa-and-spring-data) +* [Validation](https://docs.spring.io/spring-boot/docs/3.3.3/reference/htmlsingle/index.html#io.validation) + +### Guides +The following guides illustrate how to use some features concretely: + +* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) +* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) +* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) +* [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/) +* [Accessing data with MySQL](https://spring.io/guides/gs/accessing-data-mysql/) +* [Validation](https://spring.io/guides/gs/validating-form-input/) + +### Additional Links +These additional references should also help you: + +* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle) + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..13d01b9 --- /dev/null +++ b/build.gradle @@ -0,0 +1,60 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.3' + id 'io.spring.dependency-management' version '1.1.6' +} + +group = 'notai' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + + // Database + runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.mysql:mysql-connector-j' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + + // JWT + compileOnly 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + // QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..0aaefbc --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 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/HEAD/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 +' "$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 + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# 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" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + 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, 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" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@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 + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..0f5036d --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'backend' diff --git a/src/main/java/notai/BackendApplication.java b/src/main/java/notai/BackendApplication.java new file mode 100644 index 0000000..3fae6cd --- /dev/null +++ b/src/main/java/notai/BackendApplication.java @@ -0,0 +1,15 @@ +package notai; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@ConfigurationPropertiesScan +@SpringBootApplication +public class BackendApplication { + + public static void main(String[] args) { + SpringApplication.run(BackendApplication.class, args); + } + +} diff --git a/src/main/java/notai/common/config/JpaConfig.java b/src/main/java/notai/common/config/JpaConfig.java new file mode 100644 index 0000000..354b784 --- /dev/null +++ b/src/main/java/notai/common/config/JpaConfig.java @@ -0,0 +1,9 @@ +package notai.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfig { +} diff --git a/src/main/java/notai/common/config/QueryDslConfig.java b/src/main/java/notai/common/config/QueryDslConfig.java new file mode 100644 index 0000000..e384e76 --- /dev/null +++ b/src/main/java/notai/common/config/QueryDslConfig.java @@ -0,0 +1,19 @@ +package notai.common.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class QueryDslConfig { + + private final EntityManager em; + + @Bean + public JPAQueryFactory queryFactory() { + return new JPAQueryFactory(em); + } +} diff --git a/src/main/java/notai/common/config/SwaggerConfig.java b/src/main/java/notai/common/config/SwaggerConfig.java new file mode 100644 index 0000000..7ac2250 --- /dev/null +++ b/src/main/java/notai/common/config/SwaggerConfig.java @@ -0,0 +1,51 @@ +package notai.common.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + private final String serverUrl; + + public SwaggerConfig(@Value("${server-url}") String serverUrl) { + this.serverUrl = serverUrl; + } + + @Bean + public OpenAPI openAPI() { + String jwt = "JWT"; + SecurityRequirement securityRequirement = new SecurityRequirement() + .addList(jwt); + Components components = new Components() + .addSecuritySchemes(jwt, new SecurityScheme() + .name(jwt) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .description("토큰값을 입력하여 인증을 활성화할 수 있습니다.") + .bearerFormat("JWT") + ); + Server server = new Server(); + server.setUrl(serverUrl); + return new OpenAPI() + .components(new Components()) + .info(apiInfo()) + .addSecurityItem(securityRequirement) + .components(components) + .addServersItem(server); + } + + private Info apiInfo() { + return new Info() + .title("notai API") + .description("notai API 문서입니다.") + .version("0.0.1"); + } +} diff --git a/src/main/java/notai/common/config/WebConfig.java b/src/main/java/notai/common/config/WebConfig.java new file mode 100644 index 0000000..2c4cdea --- /dev/null +++ b/src/main/java/notai/common/config/WebConfig.java @@ -0,0 +1,8 @@ +package notai.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { +} diff --git a/src/main/java/notai/common/converter/JsonAttributeConverter.java b/src/main/java/notai/common/converter/JsonAttributeConverter.java new file mode 100644 index 0000000..8641a8b --- /dev/null +++ b/src/main/java/notai/common/converter/JsonAttributeConverter.java @@ -0,0 +1,39 @@ +package notai.common.converter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import notai.common.exception.type.JsonConversionException; + +import java.io.IOException; + +@Converter +public class JsonAttributeConverter implements AttributeConverter { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final TypeReference typeReference; + + public JsonAttributeConverter(TypeReference typeReference) { + this.typeReference = typeReference; + } + + @Override + public String convertToDatabaseColumn(T attribute) { + try { + return objectMapper.writeValueAsString(attribute); + } catch (JsonProcessingException e) { + throw new JsonConversionException("객체를 JSON 문자열로 변환하는 중 오류가 발생했습니다."); + } + } + + @Override + public T convertToEntityAttribute(String dbData) { + try { + return objectMapper.readValue(dbData, typeReference); + } catch (IOException e) { + throw new JsonConversionException("JSON 문자열을 객체로 변환하는 중 오류가 발생했습니다."); + } + } +} diff --git a/src/main/java/notai/common/domain/RootEntity.java b/src/main/java/notai/common/domain/RootEntity.java new file mode 100644 index 0000000..c8220c2 --- /dev/null +++ b/src/main/java/notai/common/domain/RootEntity.java @@ -0,0 +1,49 @@ +package notai.common.domain; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.domain.AbstractAggregateRoot; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.Objects; + +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@EntityListeners(AuditingEntityListener.class) +@MappedSuperclass +public abstract class RootEntity extends AbstractAggregateRoot> { + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; + + public abstract ID getId(); + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof RootEntity that)) { + return false; + } + if (getId() == null || that.getId() == null) { + return false; + } + return Objects.equals(getId(), that.getId()); + } + + @Override + public int hashCode() { + return Objects.hash(getId()); + } +} diff --git a/src/main/java/notai/common/exception/ApplicationException.java b/src/main/java/notai/common/exception/ApplicationException.java new file mode 100644 index 0000000..67aa9ca --- /dev/null +++ b/src/main/java/notai/common/exception/ApplicationException.java @@ -0,0 +1,14 @@ +package notai.common.exception; + +import lombok.Getter; + +@Getter +public class ApplicationException extends RuntimeException { + + private final int code; + + public ApplicationException(String message, int code) { + super(message); + this.code = code; + } +} diff --git a/src/main/java/notai/common/exception/ExceptionControllerAdvice.java b/src/main/java/notai/common/exception/ExceptionControllerAdvice.java new file mode 100644 index 0000000..a8a8b79 --- /dev/null +++ b/src/main/java/notai/common/exception/ExceptionControllerAdvice.java @@ -0,0 +1,113 @@ +package notai.common.exception; + + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; +import org.springframework.web.servlet.resource.NoResourceFoundException; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.WebUtils; + +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@RequiredArgsConstructor +@RestControllerAdvice +public class ExceptionControllerAdvice extends ResponseEntityExceptionHandler { + + @ExceptionHandler(ApplicationException.class) + ResponseEntity handleException(HttpServletRequest request, ApplicationException e) { + + log.info("잘못된 요청이 들어왔습니다. uri: {} {}, 내용: {}", + request.getMethod(), request.getRequestURI(), e.getMessage()); + + requestLogging(request); + + return ResponseEntity.status(e.getCode()) + .body(new ExceptionResponse(e.getMessage())); + } + + @ExceptionHandler(Exception.class) + ResponseEntity handleException(HttpServletRequest request, Exception e) { + log.error("예상하지 못한 예외가 발생했습니다. uri: {} {}, ", request.getMethod(), request.getRequestURI(), e); + + requestLogging(request); + return ResponseEntity.internalServerError() + .body(new ExceptionResponse(e.getMessage())); + } + + @Override + protected ResponseEntity handleNoResourceFoundException( + NoResourceFoundException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { + return new ResponseEntity<>(status); + } + + @Override + protected ResponseEntity handleExceptionInternal( + Exception e, + Object body, + HttpHeaders headers, + HttpStatusCode statusCode, + WebRequest webRequest + ) { + HttpServletRequest request = ((ServletWebRequest) webRequest).getRequest(); + log.error("예외가 발생했습니다. uri: {} {}, ", request.getMethod(), request.getRequestURI(), e); + + requestLogging(request); + return ResponseEntity.status(statusCode) + .body(new ExceptionResponse(e.getMessage())); + } + + + private void requestLogging(HttpServletRequest request) { + log.info("request header: {}", getHeaders(request)); + log.info("request query string: {}", getQueryString(request)); + log.info("request body: {}", getRequestBody(request)); + } + + private Map getHeaders(HttpServletRequest request) { + Map headerMap = new HashMap<>(); + Enumeration headerArray = request.getHeaderNames(); + while (headerArray.hasMoreElements()) { + String headerName = headerArray.nextElement(); + headerMap.put(headerName, request.getHeader(headerName)); + } + return headerMap; + } + + private String getQueryString(HttpServletRequest httpRequest) { + String queryString = httpRequest.getQueryString(); + if (queryString == null) { + return " - "; + } + return queryString; + } + + private String getRequestBody(HttpServletRequest request) { + var wrapper = WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class); + if (wrapper == null) { + return " - "; + } + try { + // body 가 읽히지 않고 예외처리 되는 경우에 캐시하기 위함 + wrapper.getInputStream().readAllBytes(); + byte[] buf = wrapper.getContentAsByteArray(); + if (buf.length == 0) { + return " - "; + } + return new String(buf, wrapper.getCharacterEncoding()); + } catch (Exception e) { + return " - "; + } + } +} diff --git a/src/main/java/notai/common/exception/ExceptionResponse.java b/src/main/java/notai/common/exception/ExceptionResponse.java new file mode 100644 index 0000000..dff2f3d --- /dev/null +++ b/src/main/java/notai/common/exception/ExceptionResponse.java @@ -0,0 +1,6 @@ +package notai.common.exception; + +public record ExceptionResponse( + String message +) { +} diff --git a/src/main/java/notai/common/exception/type/BadRequestException.java b/src/main/java/notai/common/exception/type/BadRequestException.java new file mode 100644 index 0000000..80c9ba2 --- /dev/null +++ b/src/main/java/notai/common/exception/type/BadRequestException.java @@ -0,0 +1,11 @@ +package notai.common.exception.type; + + +import notai.common.exception.ApplicationException; + +public class BadRequestException extends ApplicationException { + + public BadRequestException(String message) { + super(message, 400); + } +} diff --git a/src/main/java/notai/common/exception/type/ConflictException.java b/src/main/java/notai/common/exception/type/ConflictException.java new file mode 100644 index 0000000..9d99f18 --- /dev/null +++ b/src/main/java/notai/common/exception/type/ConflictException.java @@ -0,0 +1,11 @@ +package notai.common.exception.type; + + +import notai.common.exception.ApplicationException; + +public class ConflictException extends ApplicationException { + + public ConflictException(String message) { + super(message, 409); + } +} diff --git a/src/main/java/notai/common/exception/type/ExternalApiException.java b/src/main/java/notai/common/exception/type/ExternalApiException.java new file mode 100644 index 0000000..7783837 --- /dev/null +++ b/src/main/java/notai/common/exception/type/ExternalApiException.java @@ -0,0 +1,11 @@ +package notai.common.exception.type; + + +import notai.common.exception.ApplicationException; + +public class ExternalApiException extends ApplicationException { + + public ExternalApiException(String message, int code) { + super("외부 API 호출 시 예외 발생: " + message, code); + } +} diff --git a/src/main/java/notai/common/exception/type/ForbiddenException.java b/src/main/java/notai/common/exception/type/ForbiddenException.java new file mode 100644 index 0000000..f8770c8 --- /dev/null +++ b/src/main/java/notai/common/exception/type/ForbiddenException.java @@ -0,0 +1,11 @@ +package notai.common.exception.type; + + +import notai.common.exception.ApplicationException; + +public class ForbiddenException extends ApplicationException { + + public ForbiddenException(String message) { + super(message, 403); + } +} diff --git a/src/main/java/notai/common/exception/type/InternalServerErrorException.java b/src/main/java/notai/common/exception/type/InternalServerErrorException.java new file mode 100644 index 0000000..af0c3a4 --- /dev/null +++ b/src/main/java/notai/common/exception/type/InternalServerErrorException.java @@ -0,0 +1,11 @@ +package notai.common.exception.type; + + +import notai.common.exception.ApplicationException; + +public class InternalServerErrorException extends ApplicationException { + + public InternalServerErrorException(String message) { + super(message, 500); + } +} diff --git a/src/main/java/notai/common/exception/type/JsonConversionException.java b/src/main/java/notai/common/exception/type/JsonConversionException.java new file mode 100644 index 0000000..962e327 --- /dev/null +++ b/src/main/java/notai/common/exception/type/JsonConversionException.java @@ -0,0 +1,10 @@ +package notai.common.exception.type; + +import notai.common.exception.ApplicationException; + +public class JsonConversionException extends ApplicationException { + + public JsonConversionException(String message) { + super(message, 500); + } +} diff --git a/src/main/java/notai/common/exception/type/NotFoundException.java b/src/main/java/notai/common/exception/type/NotFoundException.java new file mode 100644 index 0000000..20b23f1 --- /dev/null +++ b/src/main/java/notai/common/exception/type/NotFoundException.java @@ -0,0 +1,11 @@ +package notai.common.exception.type; + + +import notai.common.exception.ApplicationException; + +public class NotFoundException extends ApplicationException { + + public NotFoundException(String message) { + super(message, 404); + } +} diff --git a/src/main/java/notai/common/exception/type/UnAuthorizedException.java b/src/main/java/notai/common/exception/type/UnAuthorizedException.java new file mode 100644 index 0000000..af03c78 --- /dev/null +++ b/src/main/java/notai/common/exception/type/UnAuthorizedException.java @@ -0,0 +1,11 @@ +package notai.common.exception.type; + + +import notai.common.exception.ApplicationException; + +public class UnAuthorizedException extends ApplicationException { + + public UnAuthorizedException(String message) { + super(message, 401); + } +} diff --git a/src/main/java/notai/common/utils/Math.java b/src/main/java/notai/common/utils/Math.java new file mode 100644 index 0000000..735199e --- /dev/null +++ b/src/main/java/notai/common/utils/Math.java @@ -0,0 +1,5 @@ +package notai.common.utils; + +// 공통된 계삭관련 로직 처리를 위한 유틸 클래스 +public class Math { +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..6ca2099 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,36 @@ +logging: + level: + org.hibernate.orm.jdbc.bind: TRACE + +spring: + h2: + console: + enabled: true + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:test;MODE=MYSQL;DB_CLOSE_DELAY=-1 + username: sa + password: + sql: + init: + mode: always + platform: h2 + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + use_sql_comments: true + highlight_sql: true + dialect: org.hibernate.dialect.MySQLDialect + hibernate: + ddl-auto: create + defer-datasource-initialization: true + +server: + servlet: + encoding: + charset: UTF-8 + force: true + +server-url: http://localhost:8080 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..d74c444 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + profiles: + active: local diff --git a/src/test/java/notai/BackendApplicationTests.java b/src/test/java/notai/BackendApplicationTests.java new file mode 100644 index 0000000..b50683a --- /dev/null +++ b/src/test/java/notai/BackendApplicationTests.java @@ -0,0 +1,13 @@ +package notai; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class BackendApplicationTests { + + @Test + void contextLoads() { + } + +} From dc2fc4841dc902613c92f88d0f1b78096b204e89 Mon Sep 17 00:00:00 2001 From: yugyeom <48901587+rladbrua0207@users.noreply.github.com> Date: Fri, 20 Sep 2024 22:01:06 +0900 Subject: [PATCH 02/12] =?UTF-8?q?Feat:=20Member=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: Member 도메인 구현 * Feat: Kakao 로그인 Client로직 구현 - 안드로이드에서 전달 된 이미 검증이 된 Kakao accesstoken으로 Member조회 * Test: Kakao 로그인 fetchMember 단위테스트 작성 * Feat: JWT 관련 기능 구현 - Interceptor에서 특정 path를 제외하고 토큰이 유효한지 검증 - @Auth 어노테이션이 붙어있으면서 Long타입인 파라미터인 경우 ArgumentResolver에서 토큰검증 및 memberId를 반환해주도록 구현 * Feat: Member API 구현 * Style: 불필요한 import 제거 * Test: 하드코딩 된 값 변수추출 * Refactor: 불필요한 토큰 검증 제거 - Interceptor에서 검증된 토큰으로 memberId를 추출해 ArgumenResolver에서 그 memberId를 사용할 수 있도록 개선 --- src/main/java/notai/auth/Auth.java | 15 ++++ .../java/notai/auth/AuthArgumentResolver.java | 37 ++++++++ src/main/java/notai/auth/TokenPair.java | 4 + src/main/java/notai/auth/TokenProperty.java | 11 +++ src/main/java/notai/auth/TokenService.java | 84 +++++++++++++++++++ .../java/notai/client/HttpInterfaceUtil.java | 13 +++ .../java/notai/client/oauth/OauthClient.java | 11 +++ .../client/oauth/OauthClientComposite.java | 33 ++++++++ .../notai/client/oauth/kakao/KakaoClient.java | 12 +++ .../client/oauth/kakao/KakaoClientConfig.java | 27 ++++++ .../oauth/kakao/KakaoMemberResponse.java | 38 +++++++++ .../client/oauth/kakao/KakaoOauthClient.java | 27 ++++++ .../java/notai/common/config/AuthConfig.java | 30 +++++++ .../notai/common/config/AuthInterceptor.java | 35 ++++++++ .../java/notai/common/config/WebConfig.java | 8 -- .../application/MemberQueryService.java | 17 ++++ .../member/application/MemberService.java | 21 +++++ .../application/result/MemberFindResult.java | 15 ++++ src/main/java/notai/member/domain/Member.java | 44 ++++++++++ .../notai/member/domain/MemberRepository.java | 24 ++++++ .../java/notai/member/domain/OauthId.java | 25 ++++++ .../notai/member/domain/OauthProvider.java | 5 ++ .../member/presentation/MemberController.java | 55 ++++++++++++ .../request/OauthLoginRequest.java | 6 ++ .../request/TokenRefreshRequest.java | 6 ++ .../response/MemberFindResponse.java | 15 ++++ .../response/MemberOauthLoginResopnse.java | 12 +++ .../response/MemberTokenRefreshResponse.java | 12 +++ src/main/resources/application-local.yml | 5 ++ .../oauth/kakao/KakaoOauthClientTest.java | 60 +++++++++++++ 30 files changed, 699 insertions(+), 8 deletions(-) create mode 100644 src/main/java/notai/auth/Auth.java create mode 100644 src/main/java/notai/auth/AuthArgumentResolver.java create mode 100644 src/main/java/notai/auth/TokenPair.java create mode 100644 src/main/java/notai/auth/TokenProperty.java create mode 100644 src/main/java/notai/auth/TokenService.java create mode 100644 src/main/java/notai/client/HttpInterfaceUtil.java create mode 100644 src/main/java/notai/client/oauth/OauthClient.java create mode 100644 src/main/java/notai/client/oauth/OauthClientComposite.java create mode 100644 src/main/java/notai/client/oauth/kakao/KakaoClient.java create mode 100644 src/main/java/notai/client/oauth/kakao/KakaoClientConfig.java create mode 100644 src/main/java/notai/client/oauth/kakao/KakaoMemberResponse.java create mode 100644 src/main/java/notai/client/oauth/kakao/KakaoOauthClient.java create mode 100644 src/main/java/notai/common/config/AuthConfig.java create mode 100644 src/main/java/notai/common/config/AuthInterceptor.java delete mode 100644 src/main/java/notai/common/config/WebConfig.java create mode 100644 src/main/java/notai/member/application/MemberQueryService.java create mode 100644 src/main/java/notai/member/application/MemberService.java create mode 100644 src/main/java/notai/member/application/result/MemberFindResult.java create mode 100644 src/main/java/notai/member/domain/Member.java create mode 100644 src/main/java/notai/member/domain/MemberRepository.java create mode 100644 src/main/java/notai/member/domain/OauthId.java create mode 100644 src/main/java/notai/member/domain/OauthProvider.java create mode 100644 src/main/java/notai/member/presentation/MemberController.java create mode 100644 src/main/java/notai/member/presentation/request/OauthLoginRequest.java create mode 100644 src/main/java/notai/member/presentation/request/TokenRefreshRequest.java create mode 100644 src/main/java/notai/member/presentation/response/MemberFindResponse.java create mode 100644 src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java create mode 100644 src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java create mode 100644 src/test/java/notai/client/oauth/kakao/KakaoOauthClientTest.java diff --git a/src/main/java/notai/auth/Auth.java b/src/main/java/notai/auth/Auth.java new file mode 100644 index 0000000..d3eedd0 --- /dev/null +++ b/src/main/java/notai/auth/Auth.java @@ -0,0 +1,15 @@ +package notai.auth; + +import io.swagger.v3.oas.annotations.Hidden; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Hidden +@Target(PARAMETER) +@Retention(RUNTIME) +public @interface Auth { +} diff --git a/src/main/java/notai/auth/AuthArgumentResolver.java b/src/main/java/notai/auth/AuthArgumentResolver.java new file mode 100644 index 0000000..6f951a8 --- /dev/null +++ b/src/main/java/notai/auth/AuthArgumentResolver.java @@ -0,0 +1,37 @@ +package notai.auth; + + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import notai.member.domain.MemberRepository; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@RequiredArgsConstructor +@Component +public class AuthArgumentResolver implements HandlerMethodArgumentResolver { + + private final MemberRepository memberRepository; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(Auth.class) + && parameter.getParameterType().equals(Long.class); + } + + @Override + public Long resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + Long memberId = (Long) request.getAttribute("memberId"); + return memberRepository.getById(memberId).getId(); + } +} diff --git a/src/main/java/notai/auth/TokenPair.java b/src/main/java/notai/auth/TokenPair.java new file mode 100644 index 0000000..4e51456 --- /dev/null +++ b/src/main/java/notai/auth/TokenPair.java @@ -0,0 +1,4 @@ +package notai.auth; + +public record TokenPair(String accessToken, String refreshToken) { +} diff --git a/src/main/java/notai/auth/TokenProperty.java b/src/main/java/notai/auth/TokenProperty.java new file mode 100644 index 0000000..ddee3f2 --- /dev/null +++ b/src/main/java/notai/auth/TokenProperty.java @@ -0,0 +1,11 @@ +package notai.auth; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("token") +public record TokenProperty( + String secretKey, + long accessTokenExpirationMillis, + long refreshTokenExpirationMillis +) { +} diff --git a/src/main/java/notai/auth/TokenService.java b/src/main/java/notai/auth/TokenService.java new file mode 100644 index 0000000..b4b8510 --- /dev/null +++ b/src/main/java/notai/auth/TokenService.java @@ -0,0 +1,84 @@ +package notai.auth; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import notai.common.exception.type.UnAuthorizedException; +import notai.member.domain.Member; +import notai.member.domain.MemberRepository; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; + +@Component +public class TokenService { + private static final String MEMBER_ID_CLAIM = "memberId"; + + private final SecretKey secretKey; + private final long accessTokenExpirationMillis; + private final long refreshTokenExpirationMillis; + private final MemberRepository memberRepository; + + public TokenService(TokenProperty tokenProperty, MemberRepository memberRepository) { + this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(tokenProperty.secretKey())); + this.accessTokenExpirationMillis = tokenProperty.accessTokenExpirationMillis(); + this.refreshTokenExpirationMillis = tokenProperty.refreshTokenExpirationMillis(); + this.memberRepository = memberRepository; + } + + public String createAccessToken(Long memberId) { + return Jwts.builder() + .claim(MEMBER_ID_CLAIM, memberId) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + accessTokenExpirationMillis)) + .signWith(secretKey, Jwts.SIG.HS512) + .compact(); + } + + private String createRefreshToken() { + return Jwts.builder() + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + refreshTokenExpirationMillis)) + .signWith(secretKey, Jwts.SIG.HS512) + .compact(); + } + + public TokenPair createTokenPair(Long memberId) { + String accessToken = createAccessToken(memberId); + String refreshToken = createRefreshToken(); + + Member member = memberRepository.getById(memberId); + member.updateRefreshToken(refreshToken); + memberRepository.save(member); + + return new TokenPair(accessToken, refreshToken); + } + + public TokenPair refreshTokenPair(String refreshToken) { + try { + Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(refreshToken); + } catch (ExpiredJwtException e) { + throw new UnAuthorizedException("만료된 Refresh Token입니다."); + } catch (Exception e) { + throw new UnAuthorizedException("유효하지 않은 Refresh Token입니다."); + } + Member member = memberRepository.getByRefreshToken(refreshToken); + + return createTokenPair(member.getId()); + } + + public Long extractMemberId(String token) { + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload() + .get(MEMBER_ID_CLAIM, Long.class); + } catch (Exception e) { + throw new UnAuthorizedException("유효하지 않은 토큰입니다."); + } + } +} diff --git a/src/main/java/notai/client/HttpInterfaceUtil.java b/src/main/java/notai/client/HttpInterfaceUtil.java new file mode 100644 index 0000000..a2467df --- /dev/null +++ b/src/main/java/notai/client/HttpInterfaceUtil.java @@ -0,0 +1,13 @@ +package notai.client; + +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +public class HttpInterfaceUtil { + public static T createHttpInterface(RestClient restClient, Class clazz) { + HttpServiceProxyFactory build = HttpServiceProxyFactory + .builderFor(RestClientAdapter.create(restClient)).build(); + return build.createClient(clazz); + } +} diff --git a/src/main/java/notai/client/oauth/OauthClient.java b/src/main/java/notai/client/oauth/OauthClient.java new file mode 100644 index 0000000..bb6aeba --- /dev/null +++ b/src/main/java/notai/client/oauth/OauthClient.java @@ -0,0 +1,11 @@ +package notai.client.oauth; + +import notai.member.domain.Member; +import notai.member.domain.OauthProvider; + +public interface OauthClient { + + OauthProvider oauthProvider(); + + Member fetchMember(String accessToken); +} diff --git a/src/main/java/notai/client/oauth/OauthClientComposite.java b/src/main/java/notai/client/oauth/OauthClientComposite.java new file mode 100644 index 0000000..a3ae267 --- /dev/null +++ b/src/main/java/notai/client/oauth/OauthClientComposite.java @@ -0,0 +1,33 @@ +package notai.client.oauth; + +import notai.common.exception.type.BadRequestException; +import notai.member.domain.Member; +import notai.member.domain.OauthProvider; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.toMap; + +@Component +public class OauthClientComposite { + + private final Map oauthClients; + + public OauthClientComposite(Set oauthClients) { + this.oauthClients = oauthClients.stream() + .collect(toMap(OauthClient::oauthProvider, identity())); + } + + public Member fetchMember(OauthProvider oauthProvider, String accessToken) { + return oauthClients.get(oauthProvider).fetchMember(accessToken); + } + + public OauthClient getOauthClient(OauthProvider oauthProvider) { + return Optional.ofNullable(oauthClients.get(oauthProvider)).orElseThrow( + () -> new BadRequestException("지원하지 않는 소셜 로그인 타입입니다.")); + } +} diff --git a/src/main/java/notai/client/oauth/kakao/KakaoClient.java b/src/main/java/notai/client/oauth/kakao/KakaoClient.java new file mode 100644 index 0000000..a465b0d --- /dev/null +++ b/src/main/java/notai/client/oauth/kakao/KakaoClient.java @@ -0,0 +1,12 @@ +package notai.client.oauth.kakao; + +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.GetExchange; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + +public interface KakaoClient { + + @GetExchange(url = "https://kapi.kakao.com/v2/user/me") + KakaoMemberResponse fetchMember(@RequestHeader(name = AUTHORIZATION) String accessToken); +} diff --git a/src/main/java/notai/client/oauth/kakao/KakaoClientConfig.java b/src/main/java/notai/client/oauth/kakao/KakaoClientConfig.java new file mode 100644 index 0000000..3b95ea1 --- /dev/null +++ b/src/main/java/notai/client/oauth/kakao/KakaoClientConfig.java @@ -0,0 +1,27 @@ +package notai.client.oauth.kakao; + +import lombok.extern.slf4j.Slf4j; +import notai.common.exception.type.ExternalApiException; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.client.RestClient; + +import static notai.client.HttpInterfaceUtil.createHttpInterface; + +@Slf4j +@Configuration +public class KakaoClientConfig { + + @Bean + public KakaoClient kakaoClient() { + RestClient restClient = RestClient.builder() + .defaultStatusHandler(HttpStatusCode::isError, (request, response) -> { + String responseData = new String(response.getBody().readAllBytes()); + log.error("카카오톡 API 오류 : {}", responseData); + throw new ExternalApiException(responseData, response.getStatusCode().value()); + }) + .build(); + return createHttpInterface(restClient, KakaoClient.class); + } +} diff --git a/src/main/java/notai/client/oauth/kakao/KakaoMemberResponse.java b/src/main/java/notai/client/oauth/kakao/KakaoMemberResponse.java new file mode 100644 index 0000000..a6a473e --- /dev/null +++ b/src/main/java/notai/client/oauth/kakao/KakaoMemberResponse.java @@ -0,0 +1,38 @@ +package notai.client.oauth.kakao; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import notai.member.domain.Member; +import notai.member.domain.OauthId; +import notai.member.domain.OauthProvider; + +import java.time.LocalDateTime; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record KakaoMemberResponse( + Long id, + boolean hasSignedUp, + LocalDateTime connectedAt, + KakaoAccount kakaoAccount) { + + public Member toDomain() { + return new Member( + new OauthId(String.valueOf(id), OauthProvider.KAKAO), + kakaoAccount.email, + kakaoAccount.profile.nickname); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + public record KakaoAccount( + Profile profile, + boolean emailNeedsAgreement, + boolean isEmailValid, + boolean isEmailVerified, + String email) { + } + + @JsonNaming(value = SnakeCaseStrategy.class) + public record Profile( + String nickname) { + } +} diff --git a/src/main/java/notai/client/oauth/kakao/KakaoOauthClient.java b/src/main/java/notai/client/oauth/kakao/KakaoOauthClient.java new file mode 100644 index 0000000..4688f41 --- /dev/null +++ b/src/main/java/notai/client/oauth/kakao/KakaoOauthClient.java @@ -0,0 +1,27 @@ +package notai.client.oauth.kakao; + +import lombok.RequiredArgsConstructor; +import notai.client.oauth.OauthClient; +import notai.member.domain.Member; +import notai.member.domain.OauthProvider; +import org.springframework.stereotype.Component; + +import static notai.member.domain.OauthProvider.KAKAO; + +@Component +@RequiredArgsConstructor +public class KakaoOauthClient implements OauthClient { + + private final KakaoClient kakaoClient; + + @Override + public Member fetchMember(String accessToken) { + return kakaoClient.fetchMember(accessToken).toDomain(); + } + + @Override + public OauthProvider oauthProvider() { + return KAKAO; + } + +} diff --git a/src/main/java/notai/common/config/AuthConfig.java b/src/main/java/notai/common/config/AuthConfig.java new file mode 100644 index 0000000..6e4417f --- /dev/null +++ b/src/main/java/notai/common/config/AuthConfig.java @@ -0,0 +1,30 @@ +package notai.common.config; + +import lombok.RequiredArgsConstructor; +import notai.auth.AuthArgumentResolver; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@RequiredArgsConstructor +@Configuration +public class AuthConfig implements WebMvcConfigurer { + private final AuthInterceptor authInterceptor; + private final AuthArgumentResolver authArgumentResolver; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(authInterceptor) + .addPathPatterns("/api/**") + .excludePathPatterns("/api/members/oauth/login/**") + .excludePathPatterns("/api/members/token/refresh"); + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authArgumentResolver); + } +} diff --git a/src/main/java/notai/common/config/AuthInterceptor.java b/src/main/java/notai/common/config/AuthInterceptor.java new file mode 100644 index 0000000..87c168c --- /dev/null +++ b/src/main/java/notai/common/config/AuthInterceptor.java @@ -0,0 +1,35 @@ +package notai.common.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import notai.auth.TokenService; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + +@Component +public class AuthInterceptor implements HandlerInterceptor { + private final TokenService tokenService; + private static final String AUTHENTICATION_TYPE = "Bearer "; + private static final int BEARER_PREFIX_LENGTH = 7; + + public AuthInterceptor(TokenService tokenService) { + this.tokenService = tokenService; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String header = request.getHeader(AUTHORIZATION); + if (header == null || !header.startsWith(AUTHENTICATION_TYPE)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + String token = header.substring(BEARER_PREFIX_LENGTH); + Long memberId = tokenService.extractMemberId(token); + request.setAttribute("memberId", memberId); + + return true; + } +} diff --git a/src/main/java/notai/common/config/WebConfig.java b/src/main/java/notai/common/config/WebConfig.java deleted file mode 100644 index 2c4cdea..0000000 --- a/src/main/java/notai/common/config/WebConfig.java +++ /dev/null @@ -1,8 +0,0 @@ -package notai.common.config; - -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -@Configuration -public class WebConfig implements WebMvcConfigurer { -} diff --git a/src/main/java/notai/member/application/MemberQueryService.java b/src/main/java/notai/member/application/MemberQueryService.java new file mode 100644 index 0000000..98b9381 --- /dev/null +++ b/src/main/java/notai/member/application/MemberQueryService.java @@ -0,0 +1,17 @@ +package notai.member.application; + +import lombok.RequiredArgsConstructor; +import notai.member.application.result.MemberFindResult; +import notai.member.domain.MemberRepository; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class MemberQueryService { + + private final MemberRepository memberRepository; + + public MemberFindResult findById(Long memberId) { + return MemberFindResult.from(memberRepository.getById(memberId)); + } +} diff --git a/src/main/java/notai/member/application/MemberService.java b/src/main/java/notai/member/application/MemberService.java new file mode 100644 index 0000000..79ffbc8 --- /dev/null +++ b/src/main/java/notai/member/application/MemberService.java @@ -0,0 +1,21 @@ +package notai.member.application; + +import lombok.RequiredArgsConstructor; +import notai.member.domain.Member; +import notai.member.domain.MemberRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Transactional +@Service +public class MemberService { + + private final MemberRepository memberRepository; + + public Long login(Member member) { + return memberRepository.findByOauthId(member.getOauthId()) + .orElseGet(() -> memberRepository.save(member)) + .getId(); + } +} diff --git a/src/main/java/notai/member/application/result/MemberFindResult.java b/src/main/java/notai/member/application/result/MemberFindResult.java new file mode 100644 index 0000000..b8dd7c9 --- /dev/null +++ b/src/main/java/notai/member/application/result/MemberFindResult.java @@ -0,0 +1,15 @@ +package notai.member.application.result; + +import notai.member.domain.Member; + +public record MemberFindResult( + Long id, + String nickname +) { + public static MemberFindResult from(Member member) { + return new MemberFindResult( + member.getId(), + member.getNickname() + ); + } +} diff --git a/src/main/java/notai/member/domain/Member.java b/src/main/java/notai/member/domain/Member.java new file mode 100644 index 0000000..cae89ea --- /dev/null +++ b/src/main/java/notai/member/domain/Member.java @@ -0,0 +1,44 @@ +package notai.member.domain; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.common.domain.RootEntity; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Table(name = "member") +@Getter +@NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor +public class Member extends RootEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Embedded + private OauthId oauthId; + + @Column(length = 50, nullable = false) + private String email; + + @Column(length = 20, nullable = true) + private String nickname; + + @Column(length = 255, nullable = false) + private String refreshToken; + + public Member(OauthId oauthId, String email, String nickname) { + this.oauthId = oauthId; + this.email = email; + this.nickname = nickname; + } + + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } +} diff --git a/src/main/java/notai/member/domain/MemberRepository.java b/src/main/java/notai/member/domain/MemberRepository.java new file mode 100644 index 0000000..548ae9b --- /dev/null +++ b/src/main/java/notai/member/domain/MemberRepository.java @@ -0,0 +1,24 @@ +package notai.member.domain; + +import notai.common.exception.type.NotFoundException; +import notai.common.exception.type.UnAuthorizedException; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + Optional findByOauthId(OauthId oauthId); + + default Member getById(Long id) { + return findById(id).orElseThrow(() -> + new NotFoundException("회원 정보를 찾을 수 없습니다.") + ); + } + + Optional findByRefreshToken(String refreshToken); + + default Member getByRefreshToken(String refreshToken) { + return findByRefreshToken(refreshToken) + .orElseThrow(() -> new UnAuthorizedException("유효하지 않은 Refresh Token입니다.")); + } +} diff --git a/src/main/java/notai/member/domain/OauthId.java b/src/main/java/notai/member/domain/OauthId.java new file mode 100644 index 0000000..0d3bbc4 --- /dev/null +++ b/src/main/java/notai/member/domain/OauthId.java @@ -0,0 +1,25 @@ +package notai.member.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.Enumerated; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static jakarta.persistence.EnumType.STRING; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@Embeddable +@AllArgsConstructor +@NoArgsConstructor(access = PROTECTED) +public class OauthId { + + @Column(length = 255, nullable = false) + private String oauthId; + + @Enumerated(STRING) + @Column(length = 20, nullable = false) + private OauthProvider oauthProvider; +} diff --git a/src/main/java/notai/member/domain/OauthProvider.java b/src/main/java/notai/member/domain/OauthProvider.java new file mode 100644 index 0000000..ef90806 --- /dev/null +++ b/src/main/java/notai/member/domain/OauthProvider.java @@ -0,0 +1,5 @@ +package notai.member.domain; + +public enum OauthProvider { + KAKAO +} diff --git a/src/main/java/notai/member/presentation/MemberController.java b/src/main/java/notai/member/presentation/MemberController.java new file mode 100644 index 0000000..1da6230 --- /dev/null +++ b/src/main/java/notai/member/presentation/MemberController.java @@ -0,0 +1,55 @@ +package notai.member.presentation; + +import lombok.RequiredArgsConstructor; +import notai.auth.Auth; +import notai.auth.TokenPair; +import notai.auth.TokenService; +import notai.client.oauth.OauthClientComposite; +import notai.member.application.MemberQueryService; +import notai.member.application.MemberService; +import notai.member.domain.Member; +import notai.member.domain.OauthProvider; +import notai.member.presentation.request.OauthLoginRequest; +import notai.member.presentation.request.TokenRefreshRequest; +import notai.member.presentation.response.MemberFindResponse; +import notai.member.presentation.response.MemberOauthLoginResopnse; +import notai.member.presentation.response.MemberTokenRefreshResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/members/") +public class MemberController { + + private final MemberService memberService; + private final MemberQueryService memberQueryService; + private final OauthClientComposite oauthClient; + private final TokenService tokenService; + + @PostMapping("/oauth/login/{oauthProvider}") + public ResponseEntity loginWithOauth( + @PathVariable(value = "oauthProvider") OauthProvider oauthProvider, + @RequestBody OauthLoginRequest request + ) { + Member member = oauthClient.fetchMember(oauthProvider, request.oauthAccessToken()); + Long memberId = memberService.login(member); + TokenPair tokenPair = tokenService.createTokenPair(memberId); + return ResponseEntity.ok(MemberOauthLoginResopnse.from(tokenPair)); + } + + @PostMapping("/token/refresh") + public ResponseEntity refreshToken( + @RequestBody TokenRefreshRequest request + ) { + TokenPair tokenPair = tokenService.refreshTokenPair(request.refreshToken()); + return ResponseEntity.ok(MemberTokenRefreshResponse.from(tokenPair)); + } + + @GetMapping("/me") + public ResponseEntity findMyProfile( + @Auth Long memberId + ) { + return ResponseEntity.ok(MemberFindResponse.from(memberQueryService.findById(memberId))); + } +} diff --git a/src/main/java/notai/member/presentation/request/OauthLoginRequest.java b/src/main/java/notai/member/presentation/request/OauthLoginRequest.java new file mode 100644 index 0000000..5c42c5e --- /dev/null +++ b/src/main/java/notai/member/presentation/request/OauthLoginRequest.java @@ -0,0 +1,6 @@ +package notai.member.presentation.request; + +public record OauthLoginRequest( + String oauthAccessToken +) { +} diff --git a/src/main/java/notai/member/presentation/request/TokenRefreshRequest.java b/src/main/java/notai/member/presentation/request/TokenRefreshRequest.java new file mode 100644 index 0000000..1356bfd --- /dev/null +++ b/src/main/java/notai/member/presentation/request/TokenRefreshRequest.java @@ -0,0 +1,6 @@ +package notai.member.presentation.request; + +public record TokenRefreshRequest( + String refreshToken +) { +} diff --git a/src/main/java/notai/member/presentation/response/MemberFindResponse.java b/src/main/java/notai/member/presentation/response/MemberFindResponse.java new file mode 100644 index 0000000..559a7c6 --- /dev/null +++ b/src/main/java/notai/member/presentation/response/MemberFindResponse.java @@ -0,0 +1,15 @@ +package notai.member.presentation.response; + +import notai.member.application.result.MemberFindResult; + +public record MemberFindResponse( + Long id, + String nickname +) { + public static MemberFindResponse from(MemberFindResult result) { + return new MemberFindResponse( + result.id(), + result.nickname() + ); + } +} diff --git a/src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java b/src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java new file mode 100644 index 0000000..118397d --- /dev/null +++ b/src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java @@ -0,0 +1,12 @@ +package notai.member.presentation.response; + +import notai.auth.TokenPair; + +public record MemberOauthLoginResopnse( + String accessToken, + String refreshToken +) { + public static MemberOauthLoginResopnse from(TokenPair tokenPair) { + return new MemberOauthLoginResopnse(tokenPair.accessToken(), tokenPair.refreshToken()); + } +} diff --git a/src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java b/src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java new file mode 100644 index 0000000..612aed9 --- /dev/null +++ b/src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java @@ -0,0 +1,12 @@ +package notai.member.presentation.response; + +import notai.auth.TokenPair; + +public record MemberTokenRefreshResponse( + String accessToken, + String refreshToken +) { + public static MemberTokenRefreshResponse from(TokenPair tokenPair) { + return new MemberTokenRefreshResponse(tokenPair.accessToken(), tokenPair.refreshToken()); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 6ca2099..9f6d882 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -34,3 +34,8 @@ server: force: true server-url: http://localhost:8080 + +token: # todo production에서 secretKey 변경 + secretKey: "ZGQrT0tuZHZkRWRxeXJCamRYMDFKMnBaR2w5WXlyQm9HU2RqZHNha1gycFlkMWpLc0dObw==" + accessTokenExpirationMillis: 10000000000 + refreshTokenExpirationMillis: 10000000000 diff --git a/src/test/java/notai/client/oauth/kakao/KakaoOauthClientTest.java b/src/test/java/notai/client/oauth/kakao/KakaoOauthClientTest.java new file mode 100644 index 0000000..2dd7dcb --- /dev/null +++ b/src/test/java/notai/client/oauth/kakao/KakaoOauthClientTest.java @@ -0,0 +1,60 @@ +package notai.client.oauth.kakao; + +import notai.member.domain.Member; +import notai.member.domain.OauthProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +public class KakaoOauthClientTest { + + @Mock + private KakaoClient kakaoClient; + + @InjectMocks + private KakaoOauthClient kakaoOauthClient; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + public void testFetchMember() { + long id = 12345L; + String accessToken = "testAccessToken"; + String email = "email@example.com"; + boolean isEmailVerified = true; + boolean isEmailValid = true; + boolean emailNeedsAgreement = false; + LocalDateTime connectedAt = LocalDateTime.now(); + boolean hasSignedUp = true; + String nickname = "nickname"; + KakaoMemberResponse.Profile profile = new KakaoMemberResponse.Profile(nickname); + + KakaoMemberResponse.KakaoAccount kakaoAccount = new KakaoMemberResponse.KakaoAccount( + profile, + emailNeedsAgreement, + isEmailValid, + isEmailVerified, + email); + + KakaoMemberResponse kakaoMemberResponse = new KakaoMemberResponse(id, hasSignedUp, connectedAt, kakaoAccount); + + when(kakaoClient.fetchMember(accessToken)).thenReturn(kakaoMemberResponse); + + Member member = kakaoOauthClient.fetchMember(accessToken); + + assertEquals(String.valueOf(id), member.getOauthId().getOauthId()); + assertEquals(OauthProvider.KAKAO, member.getOauthId().getOauthProvider()); + assertEquals(nickname, member.getNickname()); + assertEquals(email, member.getEmail()); + } +} From 077b175d8ae8e1aa9ac6a073865ca3dcbb49cad5 Mon Sep 17 00:00:00 2001 From: yugyeom <48901587+rladbrua0207@users.noreply.github.com> Date: Fri, 20 Sep 2024 22:04:19 +0900 Subject: [PATCH 03/12] =?UTF-8?q?Feat:=20Member=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: Member 도메인 구현 * Feat: Kakao 로그인 Client로직 구현 - 안드로이드에서 전달 된 이미 검증이 된 Kakao accesstoken으로 Member조회 * Test: Kakao 로그인 fetchMember 단위테스트 작성 * Feat: JWT 관련 기능 구현 - Interceptor에서 특정 path를 제외하고 토큰이 유효한지 검증 - @Auth 어노테이션이 붙어있으면서 Long타입인 파라미터인 경우 ArgumentResolver에서 토큰검증 및 memberId를 반환해주도록 구현 * Feat: Member API 구현 * Style: 불필요한 import 제거 * Test: 하드코딩 된 값 변수추출 * Refactor: 불필요한 토큰 검증 제거 - Interceptor에서 검증된 토큰으로 memberId를 추출해 ArgumenResolver에서 그 memberId를 사용할 수 있도록 개선 --- src/main/java/notai/auth/Auth.java | 15 ++++ .../java/notai/auth/AuthArgumentResolver.java | 37 ++++++++ src/main/java/notai/auth/TokenPair.java | 4 + src/main/java/notai/auth/TokenProperty.java | 11 +++ src/main/java/notai/auth/TokenService.java | 84 +++++++++++++++++++ .../java/notai/client/HttpInterfaceUtil.java | 13 +++ .../java/notai/client/oauth/OauthClient.java | 11 +++ .../client/oauth/OauthClientComposite.java | 33 ++++++++ .../notai/client/oauth/kakao/KakaoClient.java | 12 +++ .../client/oauth/kakao/KakaoClientConfig.java | 27 ++++++ .../oauth/kakao/KakaoMemberResponse.java | 38 +++++++++ .../client/oauth/kakao/KakaoOauthClient.java | 27 ++++++ .../java/notai/common/config/AuthConfig.java | 30 +++++++ .../notai/common/config/AuthInterceptor.java | 35 ++++++++ .../java/notai/common/config/WebConfig.java | 8 -- .../application/MemberQueryService.java | 17 ++++ .../member/application/MemberService.java | 21 +++++ .../application/result/MemberFindResult.java | 15 ++++ src/main/java/notai/member/domain/Member.java | 44 ++++++++++ .../notai/member/domain/MemberRepository.java | 24 ++++++ .../java/notai/member/domain/OauthId.java | 25 ++++++ .../notai/member/domain/OauthProvider.java | 5 ++ .../member/presentation/MemberController.java | 55 ++++++++++++ .../request/OauthLoginRequest.java | 6 ++ .../request/TokenRefreshRequest.java | 6 ++ .../response/MemberFindResponse.java | 15 ++++ .../response/MemberOauthLoginResopnse.java | 12 +++ .../response/MemberTokenRefreshResponse.java | 12 +++ src/main/resources/application-local.yml | 5 ++ .../oauth/kakao/KakaoOauthClientTest.java | 60 +++++++++++++ 30 files changed, 699 insertions(+), 8 deletions(-) create mode 100644 src/main/java/notai/auth/Auth.java create mode 100644 src/main/java/notai/auth/AuthArgumentResolver.java create mode 100644 src/main/java/notai/auth/TokenPair.java create mode 100644 src/main/java/notai/auth/TokenProperty.java create mode 100644 src/main/java/notai/auth/TokenService.java create mode 100644 src/main/java/notai/client/HttpInterfaceUtil.java create mode 100644 src/main/java/notai/client/oauth/OauthClient.java create mode 100644 src/main/java/notai/client/oauth/OauthClientComposite.java create mode 100644 src/main/java/notai/client/oauth/kakao/KakaoClient.java create mode 100644 src/main/java/notai/client/oauth/kakao/KakaoClientConfig.java create mode 100644 src/main/java/notai/client/oauth/kakao/KakaoMemberResponse.java create mode 100644 src/main/java/notai/client/oauth/kakao/KakaoOauthClient.java create mode 100644 src/main/java/notai/common/config/AuthConfig.java create mode 100644 src/main/java/notai/common/config/AuthInterceptor.java delete mode 100644 src/main/java/notai/common/config/WebConfig.java create mode 100644 src/main/java/notai/member/application/MemberQueryService.java create mode 100644 src/main/java/notai/member/application/MemberService.java create mode 100644 src/main/java/notai/member/application/result/MemberFindResult.java create mode 100644 src/main/java/notai/member/domain/Member.java create mode 100644 src/main/java/notai/member/domain/MemberRepository.java create mode 100644 src/main/java/notai/member/domain/OauthId.java create mode 100644 src/main/java/notai/member/domain/OauthProvider.java create mode 100644 src/main/java/notai/member/presentation/MemberController.java create mode 100644 src/main/java/notai/member/presentation/request/OauthLoginRequest.java create mode 100644 src/main/java/notai/member/presentation/request/TokenRefreshRequest.java create mode 100644 src/main/java/notai/member/presentation/response/MemberFindResponse.java create mode 100644 src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java create mode 100644 src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java create mode 100644 src/test/java/notai/client/oauth/kakao/KakaoOauthClientTest.java diff --git a/src/main/java/notai/auth/Auth.java b/src/main/java/notai/auth/Auth.java new file mode 100644 index 0000000..d3eedd0 --- /dev/null +++ b/src/main/java/notai/auth/Auth.java @@ -0,0 +1,15 @@ +package notai.auth; + +import io.swagger.v3.oas.annotations.Hidden; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Hidden +@Target(PARAMETER) +@Retention(RUNTIME) +public @interface Auth { +} diff --git a/src/main/java/notai/auth/AuthArgumentResolver.java b/src/main/java/notai/auth/AuthArgumentResolver.java new file mode 100644 index 0000000..6f951a8 --- /dev/null +++ b/src/main/java/notai/auth/AuthArgumentResolver.java @@ -0,0 +1,37 @@ +package notai.auth; + + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import notai.member.domain.MemberRepository; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@RequiredArgsConstructor +@Component +public class AuthArgumentResolver implements HandlerMethodArgumentResolver { + + private final MemberRepository memberRepository; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(Auth.class) + && parameter.getParameterType().equals(Long.class); + } + + @Override + public Long resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + Long memberId = (Long) request.getAttribute("memberId"); + return memberRepository.getById(memberId).getId(); + } +} diff --git a/src/main/java/notai/auth/TokenPair.java b/src/main/java/notai/auth/TokenPair.java new file mode 100644 index 0000000..4e51456 --- /dev/null +++ b/src/main/java/notai/auth/TokenPair.java @@ -0,0 +1,4 @@ +package notai.auth; + +public record TokenPair(String accessToken, String refreshToken) { +} diff --git a/src/main/java/notai/auth/TokenProperty.java b/src/main/java/notai/auth/TokenProperty.java new file mode 100644 index 0000000..ddee3f2 --- /dev/null +++ b/src/main/java/notai/auth/TokenProperty.java @@ -0,0 +1,11 @@ +package notai.auth; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("token") +public record TokenProperty( + String secretKey, + long accessTokenExpirationMillis, + long refreshTokenExpirationMillis +) { +} diff --git a/src/main/java/notai/auth/TokenService.java b/src/main/java/notai/auth/TokenService.java new file mode 100644 index 0000000..b4b8510 --- /dev/null +++ b/src/main/java/notai/auth/TokenService.java @@ -0,0 +1,84 @@ +package notai.auth; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import notai.common.exception.type.UnAuthorizedException; +import notai.member.domain.Member; +import notai.member.domain.MemberRepository; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; + +@Component +public class TokenService { + private static final String MEMBER_ID_CLAIM = "memberId"; + + private final SecretKey secretKey; + private final long accessTokenExpirationMillis; + private final long refreshTokenExpirationMillis; + private final MemberRepository memberRepository; + + public TokenService(TokenProperty tokenProperty, MemberRepository memberRepository) { + this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(tokenProperty.secretKey())); + this.accessTokenExpirationMillis = tokenProperty.accessTokenExpirationMillis(); + this.refreshTokenExpirationMillis = tokenProperty.refreshTokenExpirationMillis(); + this.memberRepository = memberRepository; + } + + public String createAccessToken(Long memberId) { + return Jwts.builder() + .claim(MEMBER_ID_CLAIM, memberId) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + accessTokenExpirationMillis)) + .signWith(secretKey, Jwts.SIG.HS512) + .compact(); + } + + private String createRefreshToken() { + return Jwts.builder() + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + refreshTokenExpirationMillis)) + .signWith(secretKey, Jwts.SIG.HS512) + .compact(); + } + + public TokenPair createTokenPair(Long memberId) { + String accessToken = createAccessToken(memberId); + String refreshToken = createRefreshToken(); + + Member member = memberRepository.getById(memberId); + member.updateRefreshToken(refreshToken); + memberRepository.save(member); + + return new TokenPair(accessToken, refreshToken); + } + + public TokenPair refreshTokenPair(String refreshToken) { + try { + Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(refreshToken); + } catch (ExpiredJwtException e) { + throw new UnAuthorizedException("만료된 Refresh Token입니다."); + } catch (Exception e) { + throw new UnAuthorizedException("유효하지 않은 Refresh Token입니다."); + } + Member member = memberRepository.getByRefreshToken(refreshToken); + + return createTokenPair(member.getId()); + } + + public Long extractMemberId(String token) { + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload() + .get(MEMBER_ID_CLAIM, Long.class); + } catch (Exception e) { + throw new UnAuthorizedException("유효하지 않은 토큰입니다."); + } + } +} diff --git a/src/main/java/notai/client/HttpInterfaceUtil.java b/src/main/java/notai/client/HttpInterfaceUtil.java new file mode 100644 index 0000000..a2467df --- /dev/null +++ b/src/main/java/notai/client/HttpInterfaceUtil.java @@ -0,0 +1,13 @@ +package notai.client; + +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +public class HttpInterfaceUtil { + public static T createHttpInterface(RestClient restClient, Class clazz) { + HttpServiceProxyFactory build = HttpServiceProxyFactory + .builderFor(RestClientAdapter.create(restClient)).build(); + return build.createClient(clazz); + } +} diff --git a/src/main/java/notai/client/oauth/OauthClient.java b/src/main/java/notai/client/oauth/OauthClient.java new file mode 100644 index 0000000..bb6aeba --- /dev/null +++ b/src/main/java/notai/client/oauth/OauthClient.java @@ -0,0 +1,11 @@ +package notai.client.oauth; + +import notai.member.domain.Member; +import notai.member.domain.OauthProvider; + +public interface OauthClient { + + OauthProvider oauthProvider(); + + Member fetchMember(String accessToken); +} diff --git a/src/main/java/notai/client/oauth/OauthClientComposite.java b/src/main/java/notai/client/oauth/OauthClientComposite.java new file mode 100644 index 0000000..a3ae267 --- /dev/null +++ b/src/main/java/notai/client/oauth/OauthClientComposite.java @@ -0,0 +1,33 @@ +package notai.client.oauth; + +import notai.common.exception.type.BadRequestException; +import notai.member.domain.Member; +import notai.member.domain.OauthProvider; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.toMap; + +@Component +public class OauthClientComposite { + + private final Map oauthClients; + + public OauthClientComposite(Set oauthClients) { + this.oauthClients = oauthClients.stream() + .collect(toMap(OauthClient::oauthProvider, identity())); + } + + public Member fetchMember(OauthProvider oauthProvider, String accessToken) { + return oauthClients.get(oauthProvider).fetchMember(accessToken); + } + + public OauthClient getOauthClient(OauthProvider oauthProvider) { + return Optional.ofNullable(oauthClients.get(oauthProvider)).orElseThrow( + () -> new BadRequestException("지원하지 않는 소셜 로그인 타입입니다.")); + } +} diff --git a/src/main/java/notai/client/oauth/kakao/KakaoClient.java b/src/main/java/notai/client/oauth/kakao/KakaoClient.java new file mode 100644 index 0000000..a465b0d --- /dev/null +++ b/src/main/java/notai/client/oauth/kakao/KakaoClient.java @@ -0,0 +1,12 @@ +package notai.client.oauth.kakao; + +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.GetExchange; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + +public interface KakaoClient { + + @GetExchange(url = "https://kapi.kakao.com/v2/user/me") + KakaoMemberResponse fetchMember(@RequestHeader(name = AUTHORIZATION) String accessToken); +} diff --git a/src/main/java/notai/client/oauth/kakao/KakaoClientConfig.java b/src/main/java/notai/client/oauth/kakao/KakaoClientConfig.java new file mode 100644 index 0000000..3b95ea1 --- /dev/null +++ b/src/main/java/notai/client/oauth/kakao/KakaoClientConfig.java @@ -0,0 +1,27 @@ +package notai.client.oauth.kakao; + +import lombok.extern.slf4j.Slf4j; +import notai.common.exception.type.ExternalApiException; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.client.RestClient; + +import static notai.client.HttpInterfaceUtil.createHttpInterface; + +@Slf4j +@Configuration +public class KakaoClientConfig { + + @Bean + public KakaoClient kakaoClient() { + RestClient restClient = RestClient.builder() + .defaultStatusHandler(HttpStatusCode::isError, (request, response) -> { + String responseData = new String(response.getBody().readAllBytes()); + log.error("카카오톡 API 오류 : {}", responseData); + throw new ExternalApiException(responseData, response.getStatusCode().value()); + }) + .build(); + return createHttpInterface(restClient, KakaoClient.class); + } +} diff --git a/src/main/java/notai/client/oauth/kakao/KakaoMemberResponse.java b/src/main/java/notai/client/oauth/kakao/KakaoMemberResponse.java new file mode 100644 index 0000000..a6a473e --- /dev/null +++ b/src/main/java/notai/client/oauth/kakao/KakaoMemberResponse.java @@ -0,0 +1,38 @@ +package notai.client.oauth.kakao; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import notai.member.domain.Member; +import notai.member.domain.OauthId; +import notai.member.domain.OauthProvider; + +import java.time.LocalDateTime; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record KakaoMemberResponse( + Long id, + boolean hasSignedUp, + LocalDateTime connectedAt, + KakaoAccount kakaoAccount) { + + public Member toDomain() { + return new Member( + new OauthId(String.valueOf(id), OauthProvider.KAKAO), + kakaoAccount.email, + kakaoAccount.profile.nickname); + } + + @JsonNaming(value = SnakeCaseStrategy.class) + public record KakaoAccount( + Profile profile, + boolean emailNeedsAgreement, + boolean isEmailValid, + boolean isEmailVerified, + String email) { + } + + @JsonNaming(value = SnakeCaseStrategy.class) + public record Profile( + String nickname) { + } +} diff --git a/src/main/java/notai/client/oauth/kakao/KakaoOauthClient.java b/src/main/java/notai/client/oauth/kakao/KakaoOauthClient.java new file mode 100644 index 0000000..4688f41 --- /dev/null +++ b/src/main/java/notai/client/oauth/kakao/KakaoOauthClient.java @@ -0,0 +1,27 @@ +package notai.client.oauth.kakao; + +import lombok.RequiredArgsConstructor; +import notai.client.oauth.OauthClient; +import notai.member.domain.Member; +import notai.member.domain.OauthProvider; +import org.springframework.stereotype.Component; + +import static notai.member.domain.OauthProvider.KAKAO; + +@Component +@RequiredArgsConstructor +public class KakaoOauthClient implements OauthClient { + + private final KakaoClient kakaoClient; + + @Override + public Member fetchMember(String accessToken) { + return kakaoClient.fetchMember(accessToken).toDomain(); + } + + @Override + public OauthProvider oauthProvider() { + return KAKAO; + } + +} diff --git a/src/main/java/notai/common/config/AuthConfig.java b/src/main/java/notai/common/config/AuthConfig.java new file mode 100644 index 0000000..6e4417f --- /dev/null +++ b/src/main/java/notai/common/config/AuthConfig.java @@ -0,0 +1,30 @@ +package notai.common.config; + +import lombok.RequiredArgsConstructor; +import notai.auth.AuthArgumentResolver; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@RequiredArgsConstructor +@Configuration +public class AuthConfig implements WebMvcConfigurer { + private final AuthInterceptor authInterceptor; + private final AuthArgumentResolver authArgumentResolver; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(authInterceptor) + .addPathPatterns("/api/**") + .excludePathPatterns("/api/members/oauth/login/**") + .excludePathPatterns("/api/members/token/refresh"); + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authArgumentResolver); + } +} diff --git a/src/main/java/notai/common/config/AuthInterceptor.java b/src/main/java/notai/common/config/AuthInterceptor.java new file mode 100644 index 0000000..87c168c --- /dev/null +++ b/src/main/java/notai/common/config/AuthInterceptor.java @@ -0,0 +1,35 @@ +package notai.common.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import notai.auth.TokenService; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + +@Component +public class AuthInterceptor implements HandlerInterceptor { + private final TokenService tokenService; + private static final String AUTHENTICATION_TYPE = "Bearer "; + private static final int BEARER_PREFIX_LENGTH = 7; + + public AuthInterceptor(TokenService tokenService) { + this.tokenService = tokenService; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String header = request.getHeader(AUTHORIZATION); + if (header == null || !header.startsWith(AUTHENTICATION_TYPE)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + String token = header.substring(BEARER_PREFIX_LENGTH); + Long memberId = tokenService.extractMemberId(token); + request.setAttribute("memberId", memberId); + + return true; + } +} diff --git a/src/main/java/notai/common/config/WebConfig.java b/src/main/java/notai/common/config/WebConfig.java deleted file mode 100644 index 2c4cdea..0000000 --- a/src/main/java/notai/common/config/WebConfig.java +++ /dev/null @@ -1,8 +0,0 @@ -package notai.common.config; - -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -@Configuration -public class WebConfig implements WebMvcConfigurer { -} diff --git a/src/main/java/notai/member/application/MemberQueryService.java b/src/main/java/notai/member/application/MemberQueryService.java new file mode 100644 index 0000000..98b9381 --- /dev/null +++ b/src/main/java/notai/member/application/MemberQueryService.java @@ -0,0 +1,17 @@ +package notai.member.application; + +import lombok.RequiredArgsConstructor; +import notai.member.application.result.MemberFindResult; +import notai.member.domain.MemberRepository; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class MemberQueryService { + + private final MemberRepository memberRepository; + + public MemberFindResult findById(Long memberId) { + return MemberFindResult.from(memberRepository.getById(memberId)); + } +} diff --git a/src/main/java/notai/member/application/MemberService.java b/src/main/java/notai/member/application/MemberService.java new file mode 100644 index 0000000..79ffbc8 --- /dev/null +++ b/src/main/java/notai/member/application/MemberService.java @@ -0,0 +1,21 @@ +package notai.member.application; + +import lombok.RequiredArgsConstructor; +import notai.member.domain.Member; +import notai.member.domain.MemberRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Transactional +@Service +public class MemberService { + + private final MemberRepository memberRepository; + + public Long login(Member member) { + return memberRepository.findByOauthId(member.getOauthId()) + .orElseGet(() -> memberRepository.save(member)) + .getId(); + } +} diff --git a/src/main/java/notai/member/application/result/MemberFindResult.java b/src/main/java/notai/member/application/result/MemberFindResult.java new file mode 100644 index 0000000..b8dd7c9 --- /dev/null +++ b/src/main/java/notai/member/application/result/MemberFindResult.java @@ -0,0 +1,15 @@ +package notai.member.application.result; + +import notai.member.domain.Member; + +public record MemberFindResult( + Long id, + String nickname +) { + public static MemberFindResult from(Member member) { + return new MemberFindResult( + member.getId(), + member.getNickname() + ); + } +} diff --git a/src/main/java/notai/member/domain/Member.java b/src/main/java/notai/member/domain/Member.java new file mode 100644 index 0000000..cae89ea --- /dev/null +++ b/src/main/java/notai/member/domain/Member.java @@ -0,0 +1,44 @@ +package notai.member.domain; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.common.domain.RootEntity; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Table(name = "member") +@Getter +@NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor +public class Member extends RootEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Embedded + private OauthId oauthId; + + @Column(length = 50, nullable = false) + private String email; + + @Column(length = 20, nullable = true) + private String nickname; + + @Column(length = 255, nullable = false) + private String refreshToken; + + public Member(OauthId oauthId, String email, String nickname) { + this.oauthId = oauthId; + this.email = email; + this.nickname = nickname; + } + + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } +} diff --git a/src/main/java/notai/member/domain/MemberRepository.java b/src/main/java/notai/member/domain/MemberRepository.java new file mode 100644 index 0000000..548ae9b --- /dev/null +++ b/src/main/java/notai/member/domain/MemberRepository.java @@ -0,0 +1,24 @@ +package notai.member.domain; + +import notai.common.exception.type.NotFoundException; +import notai.common.exception.type.UnAuthorizedException; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + Optional findByOauthId(OauthId oauthId); + + default Member getById(Long id) { + return findById(id).orElseThrow(() -> + new NotFoundException("회원 정보를 찾을 수 없습니다.") + ); + } + + Optional findByRefreshToken(String refreshToken); + + default Member getByRefreshToken(String refreshToken) { + return findByRefreshToken(refreshToken) + .orElseThrow(() -> new UnAuthorizedException("유효하지 않은 Refresh Token입니다.")); + } +} diff --git a/src/main/java/notai/member/domain/OauthId.java b/src/main/java/notai/member/domain/OauthId.java new file mode 100644 index 0000000..0d3bbc4 --- /dev/null +++ b/src/main/java/notai/member/domain/OauthId.java @@ -0,0 +1,25 @@ +package notai.member.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.Enumerated; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static jakarta.persistence.EnumType.STRING; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@Embeddable +@AllArgsConstructor +@NoArgsConstructor(access = PROTECTED) +public class OauthId { + + @Column(length = 255, nullable = false) + private String oauthId; + + @Enumerated(STRING) + @Column(length = 20, nullable = false) + private OauthProvider oauthProvider; +} diff --git a/src/main/java/notai/member/domain/OauthProvider.java b/src/main/java/notai/member/domain/OauthProvider.java new file mode 100644 index 0000000..ef90806 --- /dev/null +++ b/src/main/java/notai/member/domain/OauthProvider.java @@ -0,0 +1,5 @@ +package notai.member.domain; + +public enum OauthProvider { + KAKAO +} diff --git a/src/main/java/notai/member/presentation/MemberController.java b/src/main/java/notai/member/presentation/MemberController.java new file mode 100644 index 0000000..1da6230 --- /dev/null +++ b/src/main/java/notai/member/presentation/MemberController.java @@ -0,0 +1,55 @@ +package notai.member.presentation; + +import lombok.RequiredArgsConstructor; +import notai.auth.Auth; +import notai.auth.TokenPair; +import notai.auth.TokenService; +import notai.client.oauth.OauthClientComposite; +import notai.member.application.MemberQueryService; +import notai.member.application.MemberService; +import notai.member.domain.Member; +import notai.member.domain.OauthProvider; +import notai.member.presentation.request.OauthLoginRequest; +import notai.member.presentation.request.TokenRefreshRequest; +import notai.member.presentation.response.MemberFindResponse; +import notai.member.presentation.response.MemberOauthLoginResopnse; +import notai.member.presentation.response.MemberTokenRefreshResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/members/") +public class MemberController { + + private final MemberService memberService; + private final MemberQueryService memberQueryService; + private final OauthClientComposite oauthClient; + private final TokenService tokenService; + + @PostMapping("/oauth/login/{oauthProvider}") + public ResponseEntity loginWithOauth( + @PathVariable(value = "oauthProvider") OauthProvider oauthProvider, + @RequestBody OauthLoginRequest request + ) { + Member member = oauthClient.fetchMember(oauthProvider, request.oauthAccessToken()); + Long memberId = memberService.login(member); + TokenPair tokenPair = tokenService.createTokenPair(memberId); + return ResponseEntity.ok(MemberOauthLoginResopnse.from(tokenPair)); + } + + @PostMapping("/token/refresh") + public ResponseEntity refreshToken( + @RequestBody TokenRefreshRequest request + ) { + TokenPair tokenPair = tokenService.refreshTokenPair(request.refreshToken()); + return ResponseEntity.ok(MemberTokenRefreshResponse.from(tokenPair)); + } + + @GetMapping("/me") + public ResponseEntity findMyProfile( + @Auth Long memberId + ) { + return ResponseEntity.ok(MemberFindResponse.from(memberQueryService.findById(memberId))); + } +} diff --git a/src/main/java/notai/member/presentation/request/OauthLoginRequest.java b/src/main/java/notai/member/presentation/request/OauthLoginRequest.java new file mode 100644 index 0000000..5c42c5e --- /dev/null +++ b/src/main/java/notai/member/presentation/request/OauthLoginRequest.java @@ -0,0 +1,6 @@ +package notai.member.presentation.request; + +public record OauthLoginRequest( + String oauthAccessToken +) { +} diff --git a/src/main/java/notai/member/presentation/request/TokenRefreshRequest.java b/src/main/java/notai/member/presentation/request/TokenRefreshRequest.java new file mode 100644 index 0000000..1356bfd --- /dev/null +++ b/src/main/java/notai/member/presentation/request/TokenRefreshRequest.java @@ -0,0 +1,6 @@ +package notai.member.presentation.request; + +public record TokenRefreshRequest( + String refreshToken +) { +} diff --git a/src/main/java/notai/member/presentation/response/MemberFindResponse.java b/src/main/java/notai/member/presentation/response/MemberFindResponse.java new file mode 100644 index 0000000..559a7c6 --- /dev/null +++ b/src/main/java/notai/member/presentation/response/MemberFindResponse.java @@ -0,0 +1,15 @@ +package notai.member.presentation.response; + +import notai.member.application.result.MemberFindResult; + +public record MemberFindResponse( + Long id, + String nickname +) { + public static MemberFindResponse from(MemberFindResult result) { + return new MemberFindResponse( + result.id(), + result.nickname() + ); + } +} diff --git a/src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java b/src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java new file mode 100644 index 0000000..118397d --- /dev/null +++ b/src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java @@ -0,0 +1,12 @@ +package notai.member.presentation.response; + +import notai.auth.TokenPair; + +public record MemberOauthLoginResopnse( + String accessToken, + String refreshToken +) { + public static MemberOauthLoginResopnse from(TokenPair tokenPair) { + return new MemberOauthLoginResopnse(tokenPair.accessToken(), tokenPair.refreshToken()); + } +} diff --git a/src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java b/src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java new file mode 100644 index 0000000..612aed9 --- /dev/null +++ b/src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java @@ -0,0 +1,12 @@ +package notai.member.presentation.response; + +import notai.auth.TokenPair; + +public record MemberTokenRefreshResponse( + String accessToken, + String refreshToken +) { + public static MemberTokenRefreshResponse from(TokenPair tokenPair) { + return new MemberTokenRefreshResponse(tokenPair.accessToken(), tokenPair.refreshToken()); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 6ca2099..9f6d882 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -34,3 +34,8 @@ server: force: true server-url: http://localhost:8080 + +token: # todo production에서 secretKey 변경 + secretKey: "ZGQrT0tuZHZkRWRxeXJCamRYMDFKMnBaR2w5WXlyQm9HU2RqZHNha1gycFlkMWpLc0dObw==" + accessTokenExpirationMillis: 10000000000 + refreshTokenExpirationMillis: 10000000000 diff --git a/src/test/java/notai/client/oauth/kakao/KakaoOauthClientTest.java b/src/test/java/notai/client/oauth/kakao/KakaoOauthClientTest.java new file mode 100644 index 0000000..2dd7dcb --- /dev/null +++ b/src/test/java/notai/client/oauth/kakao/KakaoOauthClientTest.java @@ -0,0 +1,60 @@ +package notai.client.oauth.kakao; + +import notai.member.domain.Member; +import notai.member.domain.OauthProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +public class KakaoOauthClientTest { + + @Mock + private KakaoClient kakaoClient; + + @InjectMocks + private KakaoOauthClient kakaoOauthClient; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + public void testFetchMember() { + long id = 12345L; + String accessToken = "testAccessToken"; + String email = "email@example.com"; + boolean isEmailVerified = true; + boolean isEmailValid = true; + boolean emailNeedsAgreement = false; + LocalDateTime connectedAt = LocalDateTime.now(); + boolean hasSignedUp = true; + String nickname = "nickname"; + KakaoMemberResponse.Profile profile = new KakaoMemberResponse.Profile(nickname); + + KakaoMemberResponse.KakaoAccount kakaoAccount = new KakaoMemberResponse.KakaoAccount( + profile, + emailNeedsAgreement, + isEmailValid, + isEmailVerified, + email); + + KakaoMemberResponse kakaoMemberResponse = new KakaoMemberResponse(id, hasSignedUp, connectedAt, kakaoAccount); + + when(kakaoClient.fetchMember(accessToken)).thenReturn(kakaoMemberResponse); + + Member member = kakaoOauthClient.fetchMember(accessToken); + + assertEquals(String.valueOf(id), member.getOauthId().getOauthId()); + assertEquals(OauthProvider.KAKAO, member.getOauthId().getOauthProvider()); + assertEquals(nickname, member.getNickname()); + assertEquals(email, member.getEmail()); + } +} From 206bbe9217372c2fd4a5738923abe1dd236a1e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=ED=9B=88?= <76200940+yunjunghun0116@users.noreply.github.com> Date: Mon, 23 Sep 2024 19:11:51 +0900 Subject: [PATCH 04/12] =?UTF-8?q?Feat:=20=ED=8F=B4=EB=8D=94,=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=83=9D=EC=84=B1,?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Init: 프로젝트 기본설정 세팅 - 프로젝트 생성 - .gitignore설정 - 프로젝트 의존성 추가 - application.yml 설정파일 구성 * Init: 프로젝트 기본 구조 및 공통 컴포넌트 설정 - 공통 설정 클래스 추가 (JPA, QueryDSL, Swagger, Web) - 공통 도메인 엔티티 (RootEntity) 정의 - 예외 처리 관련 클래스 및 타입 구현 - JSON 변환을 위한 AttributeConverter 추가 - 유틸리티 클래스 (Math) 추가 * Chore: Folder 도메인 폴더 구조 셋업 폴더 구조 셋업 작업 * Feat: Folder 도메인의 엔티티 생성 엔티티 생성자, 부모-자식간 연결로직 생성 * refactor: 자기 참조 관계 설정 수정 기존, 다대일 양방향 관계에서 다대일 단방향 관계로 설정하고, 삭제 등의 이슈 발생시 Service 계층에서 함수의 재귀사용을 통해 삭제할 예정 * Chore: Document 도메인 폴더 구조 셋업 폴더 구조 셋업 및 엔티티 생성 * Feat: Lombok 라이브러리 활용하여 기본 생성자 생성 기본 생성자 생성 lombok 라이브러리 활용하여 대체 * chore: name 필드의 length 50으로 설정 name 필드 (Document, Folder) 의 length = 50 으로 설정 * chore: Domain 계층의 Repository가 QueryRepository 상속받도록 함 상속 작업 수행 * chore: Member 도메인 매핑 작업 수행 Member 도메인 매핑 작업 수행 --------- Co-authored-by: rladbrua0207 <48901587+rladbrua0207@users.noreply.github.com> --- .../application/DocumentQueryService.java | 9 +++ .../document/application/DocumentService.java | 9 +++ .../java/notai/document/domain/Document.java | 54 ++++++++++++++++++ .../document/domain/DocumentRepository.java | 7 +++ .../notai/document/domain/DocumentStatus.java | 6 ++ .../presentation/DocumentController.java | 11 ++++ .../query/DocumentQueryRepository.java | 4 ++ .../query/DocumentQueryRepositoryImpl.java | 10 ++++ .../application/FolderQueryService.java | 9 +++ .../folder/application/FolderService.java | 9 +++ src/main/java/notai/folder/domain/Folder.java | 55 +++++++++++++++++++ .../notai/folder/domain/FolderRepository.java | 7 +++ .../folder/presentation/FolderController.java | 16 ++++++ .../folder/query/FolderQueryRepository.java | 4 ++ .../query/FolderQueryRepositoryImpl.java | 10 ++++ 15 files changed, 220 insertions(+) create mode 100644 src/main/java/notai/document/application/DocumentQueryService.java create mode 100644 src/main/java/notai/document/application/DocumentService.java create mode 100644 src/main/java/notai/document/domain/Document.java create mode 100644 src/main/java/notai/document/domain/DocumentRepository.java create mode 100644 src/main/java/notai/document/domain/DocumentStatus.java create mode 100644 src/main/java/notai/document/presentation/DocumentController.java create mode 100644 src/main/java/notai/document/query/DocumentQueryRepository.java create mode 100644 src/main/java/notai/document/query/DocumentQueryRepositoryImpl.java create mode 100644 src/main/java/notai/folder/application/FolderQueryService.java create mode 100644 src/main/java/notai/folder/application/FolderService.java create mode 100644 src/main/java/notai/folder/domain/Folder.java create mode 100644 src/main/java/notai/folder/domain/FolderRepository.java create mode 100644 src/main/java/notai/folder/presentation/FolderController.java create mode 100644 src/main/java/notai/folder/query/FolderQueryRepository.java create mode 100644 src/main/java/notai/folder/query/FolderQueryRepositoryImpl.java diff --git a/src/main/java/notai/document/application/DocumentQueryService.java b/src/main/java/notai/document/application/DocumentQueryService.java new file mode 100644 index 0000000..c055f04 --- /dev/null +++ b/src/main/java/notai/document/application/DocumentQueryService.java @@ -0,0 +1,9 @@ +package notai.document.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class DocumentQueryService { +} diff --git a/src/main/java/notai/document/application/DocumentService.java b/src/main/java/notai/document/application/DocumentService.java new file mode 100644 index 0000000..b2674f1 --- /dev/null +++ b/src/main/java/notai/document/application/DocumentService.java @@ -0,0 +1,9 @@ +package notai.document.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class DocumentService { +} diff --git a/src/main/java/notai/document/domain/Document.java b/src/main/java/notai/document/domain/Document.java new file mode 100644 index 0000000..6d7c629 --- /dev/null +++ b/src/main/java/notai/document/domain/Document.java @@ -0,0 +1,54 @@ +package notai.document.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.common.domain.RootEntity; +import notai.folder.domain.Folder; + +@Entity +@Table(name = "document") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Document extends RootEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "folder_id", referencedColumnName = "id") + private Folder folder; + @NotNull + @Column(name = "name", length = 50) + private String name; + @NotNull + @Column(name = "size") + private Integer size; + @NotNull + @Column(name = "total_page") + private Integer totalPage; + @NotNull + @Enumerated(value = EnumType.STRING) + @Column(name = "status") + private DocumentStatus status; + + public Document(Folder folder, String name, Integer size, Integer totalPage, DocumentStatus status) { + this.folder = folder; + this.name = name; + this.size = size; + this.totalPage = totalPage; + this.status = status; + } +} diff --git a/src/main/java/notai/document/domain/DocumentRepository.java b/src/main/java/notai/document/domain/DocumentRepository.java new file mode 100644 index 0000000..5258454 --- /dev/null +++ b/src/main/java/notai/document/domain/DocumentRepository.java @@ -0,0 +1,7 @@ +package notai.document.domain; + +import notai.document.query.DocumentQueryRepository; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DocumentRepository extends JpaRepository, DocumentQueryRepository { +} diff --git a/src/main/java/notai/document/domain/DocumentStatus.java b/src/main/java/notai/document/domain/DocumentStatus.java new file mode 100644 index 0000000..e27778f --- /dev/null +++ b/src/main/java/notai/document/domain/DocumentStatus.java @@ -0,0 +1,6 @@ +package notai.document.domain; + +public enum DocumentStatus { + EXISTS, + GARBAGE +} diff --git a/src/main/java/notai/document/presentation/DocumentController.java b/src/main/java/notai/document/presentation/DocumentController.java new file mode 100644 index 0000000..231a640 --- /dev/null +++ b/src/main/java/notai/document/presentation/DocumentController.java @@ -0,0 +1,11 @@ +package notai.document.presentation; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/api/documents") +@RequiredArgsConstructor +public class DocumentController { +} diff --git a/src/main/java/notai/document/query/DocumentQueryRepository.java b/src/main/java/notai/document/query/DocumentQueryRepository.java new file mode 100644 index 0000000..efedf3b --- /dev/null +++ b/src/main/java/notai/document/query/DocumentQueryRepository.java @@ -0,0 +1,4 @@ +package notai.document.query; + +public interface DocumentQueryRepository { +} diff --git a/src/main/java/notai/document/query/DocumentQueryRepositoryImpl.java b/src/main/java/notai/document/query/DocumentQueryRepositoryImpl.java new file mode 100644 index 0000000..345bef5 --- /dev/null +++ b/src/main/java/notai/document/query/DocumentQueryRepositoryImpl.java @@ -0,0 +1,10 @@ +package notai.document.query; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class DocumentQueryRepositoryImpl implements DocumentQueryRepository { + + private final JPAQueryFactory jpaQueryFactory; +} diff --git a/src/main/java/notai/folder/application/FolderQueryService.java b/src/main/java/notai/folder/application/FolderQueryService.java new file mode 100644 index 0000000..b51c863 --- /dev/null +++ b/src/main/java/notai/folder/application/FolderQueryService.java @@ -0,0 +1,9 @@ +package notai.folder.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class FolderQueryService { +} diff --git a/src/main/java/notai/folder/application/FolderService.java b/src/main/java/notai/folder/application/FolderService.java new file mode 100644 index 0000000..74ca5c6 --- /dev/null +++ b/src/main/java/notai/folder/application/FolderService.java @@ -0,0 +1,9 @@ +package notai.folder.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class FolderService { +} diff --git a/src/main/java/notai/folder/domain/Folder.java b/src/main/java/notai/folder/domain/Folder.java new file mode 100644 index 0000000..631a8f4 --- /dev/null +++ b/src/main/java/notai/folder/domain/Folder.java @@ -0,0 +1,55 @@ +package notai.folder.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.common.domain.RootEntity; + +@Entity +@Table(name = "folder") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Folder extends RootEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @ManyToOne + @NotNull + @JoinColumn(name = "member_id", nullable = false) + private Member member; + @NotNull + @Column(name = "name", length = 50) + private String name; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_folder_id", referencedColumnName = "id") + private Folder parentFolder; + + public Folder(Member member, String name) { + this.member = member; + this.name = name; + } + + public Folder(Member member, String name, Folder parentFolder) { + this.member = member; + this.name = name; + this.parentFolder = parentFolder; + } + + public void moveRootFolder() { + this.parentFolder = null; + } + + public void moveNewParentFolder(Folder parentFolder) { + this.parentFolder = parentFolder; + } +} diff --git a/src/main/java/notai/folder/domain/FolderRepository.java b/src/main/java/notai/folder/domain/FolderRepository.java new file mode 100644 index 0000000..40a2231 --- /dev/null +++ b/src/main/java/notai/folder/domain/FolderRepository.java @@ -0,0 +1,7 @@ +package notai.folder.domain; + +import notai.folder.query.FolderQueryRepository; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FolderRepository extends JpaRepository, FolderQueryRepository { +} diff --git a/src/main/java/notai/folder/presentation/FolderController.java b/src/main/java/notai/folder/presentation/FolderController.java new file mode 100644 index 0000000..0c0383c --- /dev/null +++ b/src/main/java/notai/folder/presentation/FolderController.java @@ -0,0 +1,16 @@ +package notai.folder.presentation; + +import lombok.RequiredArgsConstructor; +import notai.folder.application.FolderQueryService; +import notai.folder.application.FolderService; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/api/folders") +@RequiredArgsConstructor +public class FolderController { + + private final FolderService folderService; + private final FolderQueryService folderQueryService; +} diff --git a/src/main/java/notai/folder/query/FolderQueryRepository.java b/src/main/java/notai/folder/query/FolderQueryRepository.java new file mode 100644 index 0000000..93bdaee --- /dev/null +++ b/src/main/java/notai/folder/query/FolderQueryRepository.java @@ -0,0 +1,4 @@ +package notai.folder.query; + +public interface FolderQueryRepository { +} diff --git a/src/main/java/notai/folder/query/FolderQueryRepositoryImpl.java b/src/main/java/notai/folder/query/FolderQueryRepositoryImpl.java new file mode 100644 index 0000000..c7b7681 --- /dev/null +++ b/src/main/java/notai/folder/query/FolderQueryRepositoryImpl.java @@ -0,0 +1,10 @@ +package notai.folder.query; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class FolderQueryRepositoryImpl implements FolderQueryRepository { + + private final JPAQueryFactory jpaQueryFactory; +} From eeac46a8986719fa047dc56406a2cee9bb7b3fbf Mon Sep 17 00:00:00 2001 From: Hyun-Seo Jeong <90139789+hynseoj@users.noreply.github.com> Date: Mon, 23 Sep 2024 19:12:06 +0900 Subject: [PATCH 05/12] =?UTF-8?q?Feat:=20Summary,=20Problem,=20AITask=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=83=9D=EC=84=B1=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: Summary 도메인 구현 * Feat: Problem 도메인 구현 * Feat: AI Task 도메인 구현 * Feat: 요약 및 문제 생성 API 틀 작성 - 필요한 DTO, Command 클래스 작성 - 내부 구현 흐름 파악용 임시 메서드 작성 * Rename: AITask 패키지 이름 변경 * Chore: 불필요한 어노테이션 삭제 * Comment: 임시 반환값에 대한 주석 추가 * Chore: 복합어 엔티티 테이블 이름 명시 - AITask 엔티티 @Table 어노테이션 추가 --- .../aiTask/application/AITaskService.java | 38 ++++++++++++++ .../application/command/AITaskCommand.java | 10 ++++ src/main/java/notai/aiTask/domain/AITask.java | 52 +++++++++++++++++++ .../notai/aiTask/domain/AITaskRepository.java | 7 +++ .../java/notai/aiTask/domain/TaskStatus.java | 7 +++ .../aiTask/presentation/AITaskController.java | 28 ++++++++++ .../presentation/request/AITaskRequest.java | 18 +++++++ .../presentation/response/AITaskResponse.java | 12 +++++ .../problem/application/ProblemService.java | 13 +++++ .../java/notai/problem/domain/Problem.java | 32 ++++++++++++ .../problem/domain/ProblemRepository.java | 7 +++ .../summary/application/SummaryService.java | 13 +++++ .../java/notai/summary/domain/Summary.java | 32 ++++++++++++ .../summary/domain/SummaryRepository.java | 7 +++ 14 files changed, 276 insertions(+) create mode 100644 src/main/java/notai/aiTask/application/AITaskService.java create mode 100644 src/main/java/notai/aiTask/application/command/AITaskCommand.java create mode 100644 src/main/java/notai/aiTask/domain/AITask.java create mode 100644 src/main/java/notai/aiTask/domain/AITaskRepository.java create mode 100644 src/main/java/notai/aiTask/domain/TaskStatus.java create mode 100644 src/main/java/notai/aiTask/presentation/AITaskController.java create mode 100644 src/main/java/notai/aiTask/presentation/request/AITaskRequest.java create mode 100644 src/main/java/notai/aiTask/presentation/response/AITaskResponse.java create mode 100644 src/main/java/notai/problem/application/ProblemService.java create mode 100644 src/main/java/notai/problem/domain/Problem.java create mode 100644 src/main/java/notai/problem/domain/ProblemRepository.java create mode 100644 src/main/java/notai/summary/application/SummaryService.java create mode 100644 src/main/java/notai/summary/domain/Summary.java create mode 100644 src/main/java/notai/summary/domain/SummaryRepository.java diff --git a/src/main/java/notai/aiTask/application/AITaskService.java b/src/main/java/notai/aiTask/application/AITaskService.java new file mode 100644 index 0000000..e076f3e --- /dev/null +++ b/src/main/java/notai/aiTask/application/AITaskService.java @@ -0,0 +1,38 @@ +package notai.aiTask.application; + +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import notai.aiTask.application.command.AITaskCommand; +import notai.aiTask.domain.AITaskRepository; +import notai.aiTask.presentation.response.AITaskResponse; +import org.springframework.stereotype.Service; + +/** + * SummaryService 와 ExamService 는 엔티티와 관련된 로직만 처리하고 + * AI 요약 및 문제 생성 요청은 여기서 처리하는 식으로 생각했습니다. + * AI 서버와의 통신은 별도 클래스에서 처리합니다. + */ +@Service +@RequiredArgsConstructor +public class AITaskService { + + private final AITaskRepository aiTaskRepository; + + /** + * 흐름 파악용 임시 메서드 + */ + public AITaskResponse submitTask(AITaskCommand command) { + command.pages().forEach(page -> { + UUID taskId = sendRequestToAIServer(); + // TODO: command 데이터를 이용해 content 만 null 인 Summary, Problem 생성 + // TODO: Summary, Problem 과 매핑된 AITask 생성 -> 작업 상태는 모두 PENDING + }); + + return AITaskResponse.of(command.documentId(), LocalDateTime.now()); + } + + private UUID sendRequestToAIServer() { + return UUID.randomUUID(); // 임시 값, 실제 구현에선 AI 서버에서 UUID 가 반환됨. + } +} diff --git a/src/main/java/notai/aiTask/application/command/AITaskCommand.java b/src/main/java/notai/aiTask/application/command/AITaskCommand.java new file mode 100644 index 0000000..34860c4 --- /dev/null +++ b/src/main/java/notai/aiTask/application/command/AITaskCommand.java @@ -0,0 +1,10 @@ +package notai.aiTask.application.command; + +import java.util.List; + +public record AITaskCommand( + Long documentId, + List pages +) { + +} diff --git a/src/main/java/notai/aiTask/domain/AITask.java b/src/main/java/notai/aiTask/domain/AITask.java new file mode 100644 index 0000000..5f8f995 --- /dev/null +++ b/src/main/java/notai/aiTask/domain/AITask.java @@ -0,0 +1,52 @@ +package notai.aiTask.domain; + +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import java.util.UUID; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.common.domain.RootEntity; +import notai.problem.domain.Problem; +import notai.summary.domain.Summary; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +@Table(name = "ai_task") +public class AITask extends RootEntity { + + @Id + private UUID id; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "summary_id") + private Summary summary; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "problem_id") + private Problem problem; + + @NotNull + @Enumerated(value = EnumType.STRING) + @Column(length = 20) + private TaskStatus status; + + public AITask(UUID id, Summary summary, Problem problem) { + this.id = id; + this.summary = summary; + this.problem = problem; + this.status = TaskStatus.PENDING; + } +} diff --git a/src/main/java/notai/aiTask/domain/AITaskRepository.java b/src/main/java/notai/aiTask/domain/AITaskRepository.java new file mode 100644 index 0000000..39d14ae --- /dev/null +++ b/src/main/java/notai/aiTask/domain/AITaskRepository.java @@ -0,0 +1,7 @@ +package notai.aiTask.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AITaskRepository extends JpaRepository { + +} diff --git a/src/main/java/notai/aiTask/domain/TaskStatus.java b/src/main/java/notai/aiTask/domain/TaskStatus.java new file mode 100644 index 0000000..3209e9f --- /dev/null +++ b/src/main/java/notai/aiTask/domain/TaskStatus.java @@ -0,0 +1,7 @@ +package notai.aiTask.domain; + +public enum TaskStatus { + PENDING, + IN_PROGRESS, + COMPLETED +} diff --git a/src/main/java/notai/aiTask/presentation/AITaskController.java b/src/main/java/notai/aiTask/presentation/AITaskController.java new file mode 100644 index 0000000..b43cd3a --- /dev/null +++ b/src/main/java/notai/aiTask/presentation/AITaskController.java @@ -0,0 +1,28 @@ +package notai.aiTask.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import notai.aiTask.application.AITaskService; +import notai.aiTask.application.command.AITaskCommand; +import notai.aiTask.presentation.request.AITaskRequest; +import notai.aiTask.presentation.response.AITaskResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/ai/tasks") +@RequiredArgsConstructor +public class AITaskController { + + private final AITaskService aiTaskService; + + @PostMapping + public ResponseEntity submitTask(@RequestBody @Valid AITaskRequest request) { + AITaskCommand command = request.toCommand(); + AITaskResponse response = aiTaskService.submitTask(command); + return ResponseEntity.accepted().body(response); + } +} diff --git a/src/main/java/notai/aiTask/presentation/request/AITaskRequest.java b/src/main/java/notai/aiTask/presentation/request/AITaskRequest.java new file mode 100644 index 0000000..50768b7 --- /dev/null +++ b/src/main/java/notai/aiTask/presentation/request/AITaskRequest.java @@ -0,0 +1,18 @@ +package notai.aiTask.presentation.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.util.List; +import notai.aiTask.application.command.AITaskCommand; + +public record AITaskRequest( + + @NotNull(message = "문서 ID는 필수 입력 값입니다.") + Long documentId, + + List<@Positive(message = "페이지 번호는 양수여야 합니다.") Integer> pages +) { + public AITaskCommand toCommand() { + return new AITaskCommand(documentId, pages); + } +} diff --git a/src/main/java/notai/aiTask/presentation/response/AITaskResponse.java b/src/main/java/notai/aiTask/presentation/response/AITaskResponse.java new file mode 100644 index 0000000..c990a99 --- /dev/null +++ b/src/main/java/notai/aiTask/presentation/response/AITaskResponse.java @@ -0,0 +1,12 @@ +package notai.aiTask.presentation.response; + +import java.time.LocalDateTime; + +public record AITaskResponse( + Long documentId, + LocalDateTime createdAt +) { + public static AITaskResponse of(Long documentId, LocalDateTime createdAt) { + return new AITaskResponse(documentId, createdAt); + } +} diff --git a/src/main/java/notai/problem/application/ProblemService.java b/src/main/java/notai/problem/application/ProblemService.java new file mode 100644 index 0000000..624948b --- /dev/null +++ b/src/main/java/notai/problem/application/ProblemService.java @@ -0,0 +1,13 @@ +package notai.problem.application; + +import lombok.RequiredArgsConstructor; +import notai.problem.domain.ProblemRepository; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ProblemService { + + private final ProblemRepository problemRepository; + +} diff --git a/src/main/java/notai/problem/domain/Problem.java b/src/main/java/notai/problem/domain/Problem.java new file mode 100644 index 0000000..b2c6466 --- /dev/null +++ b/src/main/java/notai/problem/domain/Problem.java @@ -0,0 +1,32 @@ +package notai.problem.domain; + +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.common.domain.RootEntity; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +public class Problem extends RootEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + private Long documentId; + + @NotNull + private Integer pageNumber; + + @Column(columnDefinition = "TEXT") + private String content; +} diff --git a/src/main/java/notai/problem/domain/ProblemRepository.java b/src/main/java/notai/problem/domain/ProblemRepository.java new file mode 100644 index 0000000..da3b9bc --- /dev/null +++ b/src/main/java/notai/problem/domain/ProblemRepository.java @@ -0,0 +1,7 @@ +package notai.problem.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProblemRepository extends JpaRepository { + +} diff --git a/src/main/java/notai/summary/application/SummaryService.java b/src/main/java/notai/summary/application/SummaryService.java new file mode 100644 index 0000000..aec1604 --- /dev/null +++ b/src/main/java/notai/summary/application/SummaryService.java @@ -0,0 +1,13 @@ +package notai.summary.application; + +import lombok.RequiredArgsConstructor; +import notai.summary.domain.SummaryRepository; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class SummaryService { + + private final SummaryRepository summaryRepository; + +} diff --git a/src/main/java/notai/summary/domain/Summary.java b/src/main/java/notai/summary/domain/Summary.java new file mode 100644 index 0000000..edf773d --- /dev/null +++ b/src/main/java/notai/summary/domain/Summary.java @@ -0,0 +1,32 @@ +package notai.summary.domain; + +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.common.domain.RootEntity; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +public class Summary extends RootEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + private Long documentId; + + @NotNull + private Integer pageNumber; + + @Column(columnDefinition = "TEXT") + private String content; +} diff --git a/src/main/java/notai/summary/domain/SummaryRepository.java b/src/main/java/notai/summary/domain/SummaryRepository.java new file mode 100644 index 0000000..63085d9 --- /dev/null +++ b/src/main/java/notai/summary/domain/SummaryRepository.java @@ -0,0 +1,7 @@ +package notai.summary.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SummaryRepository extends JpaRepository { + +} From ba4c28b376c238786e13561775291fa239c91afa Mon Sep 17 00:00:00 2001 From: Cindy <93774025+Shsin9797@users.noreply.github.com> Date: Mon, 23 Sep 2024 19:12:40 +0900 Subject: [PATCH 06/12] =?UTF-8?q?Feat:=20Post=20=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: Post 기본 코드 구현 - 깃허브가 꼬여서 이전에 작성한거 다시 가지고 왔습니다.. * Fix: Post - Member Id 외래키 매핑 * Fix: Post - @NotNull 적용 --- .../post/application/PostQueryService.java | 4 ++ .../notai/post/application/PostService.java | 4 ++ src/main/java/notai/post/domain/Post.java | 46 +++++++++++++++++++ .../notai/post/domain/PostRepository.java | 9 ++++ .../post/presentation/PostController.java | 4 ++ 5 files changed, 67 insertions(+) create mode 100644 src/main/java/notai/post/application/PostQueryService.java create mode 100644 src/main/java/notai/post/application/PostService.java create mode 100644 src/main/java/notai/post/domain/Post.java create mode 100644 src/main/java/notai/post/domain/PostRepository.java create mode 100644 src/main/java/notai/post/presentation/PostController.java diff --git a/src/main/java/notai/post/application/PostQueryService.java b/src/main/java/notai/post/application/PostQueryService.java new file mode 100644 index 0000000..1d33de5 --- /dev/null +++ b/src/main/java/notai/post/application/PostQueryService.java @@ -0,0 +1,4 @@ +package notai.post.application; + +public class PostQueryService { +} diff --git a/src/main/java/notai/post/application/PostService.java b/src/main/java/notai/post/application/PostService.java new file mode 100644 index 0000000..be1c63e --- /dev/null +++ b/src/main/java/notai/post/application/PostService.java @@ -0,0 +1,4 @@ +package notai.post.application; + +public class PostService { +} diff --git a/src/main/java/notai/post/domain/Post.java b/src/main/java/notai/post/domain/Post.java new file mode 100644 index 0000000..74c4d44 --- /dev/null +++ b/src/main/java/notai/post/domain/Post.java @@ -0,0 +1,46 @@ +package notai.post.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.member.domain.Member; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor +@Entity +public class Post { + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + @ManyToOne + @NotNull + @JoinColumn(name="member_id") + private Member member; + @NotNull + @Column(length = 255) + private String title; + @NotNull + @Column(length = 255) + private String contents; + + public Post( + Member member, + String title, + String contents + ) { + this.member = member; + this.title = title; + this.contents = contents; + } +} diff --git a/src/main/java/notai/post/domain/PostRepository.java b/src/main/java/notai/post/domain/PostRepository.java new file mode 100644 index 0000000..ec76566 --- /dev/null +++ b/src/main/java/notai/post/domain/PostRepository.java @@ -0,0 +1,9 @@ +package notai.post.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PostRepository extends JpaRepository { + +} diff --git a/src/main/java/notai/post/presentation/PostController.java b/src/main/java/notai/post/presentation/PostController.java new file mode 100644 index 0000000..c13b067 --- /dev/null +++ b/src/main/java/notai/post/presentation/PostController.java @@ -0,0 +1,4 @@ +package notai.post.presentation; + +public class PostController { +} From 3935625f51e0b2ce561e2bba8ba56274c3ba1baf Mon Sep 17 00:00:00 2001 From: Cindy <93774025+Shsin9797@users.noreply.github.com> Date: Mon, 23 Sep 2024 19:13:03 +0900 Subject: [PATCH 07/12] =?UTF-8?q?Feat:=20Comment=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat : Comment 기본코드 가져오기 - 이전에 작성한 깃이 꼬여서 다시 가져왔습니다.. * Fix : Comment - Member Id 연관관계 매핑 * Fix: Comment - @NotNull 적용 --- .../application/CommentQueryService.java | 4 ++ .../comment/application/CommentService.java | 4 ++ .../java/notai/comment/domain/Comment.java | 52 +++++++++++++++++++ .../comment/domain/CommentRepository.java | 9 ++++ .../presentation/CommentController.java | 4 ++ 5 files changed, 73 insertions(+) create mode 100644 src/main/java/notai/comment/application/CommentQueryService.java create mode 100644 src/main/java/notai/comment/application/CommentService.java create mode 100644 src/main/java/notai/comment/domain/Comment.java create mode 100644 src/main/java/notai/comment/domain/CommentRepository.java create mode 100644 src/main/java/notai/comment/presentation/CommentController.java diff --git a/src/main/java/notai/comment/application/CommentQueryService.java b/src/main/java/notai/comment/application/CommentQueryService.java new file mode 100644 index 0000000..ad2ab1b --- /dev/null +++ b/src/main/java/notai/comment/application/CommentQueryService.java @@ -0,0 +1,4 @@ +package notai.comment.application; + +public class CommentQueryService { +} diff --git a/src/main/java/notai/comment/application/CommentService.java b/src/main/java/notai/comment/application/CommentService.java new file mode 100644 index 0000000..b46762e --- /dev/null +++ b/src/main/java/notai/comment/application/CommentService.java @@ -0,0 +1,4 @@ +package notai.comment.application; + +public class CommentService { +} diff --git a/src/main/java/notai/comment/domain/Comment.java b/src/main/java/notai/comment/domain/Comment.java new file mode 100644 index 0000000..2287a46 --- /dev/null +++ b/src/main/java/notai/comment/domain/Comment.java @@ -0,0 +1,52 @@ +package notai.comment.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.common.domain.RootEntity; +import notai.member.domain.Member; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Table(name = "comment") +@Getter +@NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor +public class Comment extends RootEntity { + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + @ManyToOne + @NotNull + @JoinColumn(name = "member_id") + private Member member; + @NotNull + @Column + private Long postId; + @NotNull + @Column + private Long parentCommentId; + @NotNull + @Column(length = 255) + private String content; + public Comment( + Member member, + Long postId, + String content + ) { + this.member = member; + this.postId = postId; + this.content = content; + } + +} diff --git a/src/main/java/notai/comment/domain/CommentRepository.java b/src/main/java/notai/comment/domain/CommentRepository.java new file mode 100644 index 0000000..c29be90 --- /dev/null +++ b/src/main/java/notai/comment/domain/CommentRepository.java @@ -0,0 +1,9 @@ +package notai.comment.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CommentRepository extends JpaRepository { + +} diff --git a/src/main/java/notai/comment/presentation/CommentController.java b/src/main/java/notai/comment/presentation/CommentController.java new file mode 100644 index 0000000..4f7287a --- /dev/null +++ b/src/main/java/notai/comment/presentation/CommentController.java @@ -0,0 +1,4 @@ +package notai.comment.presentation; + +public class CommentController { +} From 458ec9d16789b1c4b1a5c9ab17ea02ca99327c77 Mon Sep 17 00:00:00 2001 From: Hyun-Seo Jeong <90139789+hynseoj@users.noreply.github.com> Date: Fri, 27 Sep 2024 23:17:25 +0900 Subject: [PATCH 08/12] =?UTF-8?q?Feat:=20=EC=9A=94=EC=95=BD=20=EB=B0=8F=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=83=9D=EC=84=B1=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Style: formatting통일 (#30) * Feat: 요약 및 문제 생성 API 구현 (#32) * Rename: AI Task 도메인 이름 변경 - llm 으로 변경 * Refactor: LLM 도메인 애플리케이션 계층과 프레젠테이션 계층 응답 분리 * Feat: LLM 작업 진행 상태 확인 기능 구현 * Feat: 요약 및 문제 결과 조회 기능 구현 * Feat: 요약 및 문제 생성 기능 구현 - AI 서버와 통신하는 부분 제외하고 기능 구현 - 임시 UUID 를 통해 task 저장 * Feat: LLM 서버 콜백 기능 구현 - LLM 서버가 API 콜을 통해 페이지별 요약 및 문제 내용 전달 - task id 를 통해 조회하여 요약 내용과 문제 내용 업데이트 * Refactor: 변수 이름 변경 - 필드명 카멜케이스로 변경 * Refactor: 통일성 없는 부분 수정 - 필드명 변경 - 변수 추출 * Refactor: 예외 종류, 메서드 네이밍 변경 - LLMQueryService 예외 타입 변경 - SummaryAndProblemUpdateResponse 메서드 네이밍 변경 * Refactor: LLMQueryService 응답과 LLMController 응답 분리 --------- Co-authored-by: yugyeom <48901587+rladbrua0207@users.noreply.github.com> --- .gitignore | 2 + .../aiTask/application/AITaskService.java | 38 ---- .../application/command/AITaskCommand.java | 10 -- .../notai/aiTask/domain/AITaskRepository.java | 7 - .../aiTask/presentation/AITaskController.java | 28 --- .../presentation/request/AITaskRequest.java | 18 -- .../presentation/response/AITaskResponse.java | 12 -- src/main/java/notai/auth/Auth.java | 5 +- .../java/notai/auth/AuthArgumentResolver.java | 3 +- .../java/notai/client/HttpInterfaceUtil.java | 10 +- .../java/notai/client/oauth/OauthClient.java | 4 +- .../client/oauth/OauthClientComposite.java | 28 ++- .../client/oauth/kakao/KakaoClientConfig.java | 8 +- .../java/notai/comment/domain/Comment.java | 39 +++-- .../java/notai/common/config/AuthConfig.java | 6 +- .../notai/common/config/AuthInterceptor.java | 3 +- .../notai/common/config/SwaggerConfig.java | 24 +-- .../java/notai/common/domain/RootEntity.java | 3 +- .../exception/ExceptionControllerAdvice.java | 21 +-- .../java/notai/document/domain/Document.java | 25 ++- .../notai/document/domain/DocumentStatus.java | 3 +- src/main/java/notai/folder/domain/Folder.java | 24 ++- .../llm/application/LLMQueryService.java | 110 ++++++++++++ .../notai/llm/application/LLMService.java | 76 ++++++++ .../application/command/LLMSubmitCommand.java | 10 ++ .../SummaryAndProblemUpdateCommand.java | 11 ++ .../application/result/LLMResultsResult.java | 31 ++++ .../application/result/LLMStatusResult.java | 14 ++ .../application/result/LLMSubmitResult.java | 12 ++ .../AITask.java => llm/domain/LLM.java} | 37 ++-- .../java/notai/llm/domain/LLMRepository.java | 12 ++ .../{aiTask => llm}/domain/TaskStatus.java | 2 +- .../notai/llm/presentation/LLMController.java | 56 ++++++ .../request/LLMSubmitRequest.java | 19 ++ .../SummaryAndProblemUpdateRequest.java | 26 +++ .../response/LLMResultsResponse.java | 39 +++++ .../response/LLMStatusResponse.java | 20 +++ .../response/LLMSubmitResponse.java | 14 ++ .../SummaryAndProblemUpdateResponse.java | 9 + .../notai/llm/query/LLMQueryRepository.java | 24 +++ .../member/application/MemberService.java | 3 +- .../application/result/MemberFindResult.java | 8 +- src/main/java/notai/member/domain/Member.java | 47 ++--- .../notai/member/domain/MemberRepository.java | 7 +- .../java/notai/member/domain/OauthId.java | 18 +- .../notai/member/domain/OauthProvider.java | 2 +- .../member/presentation/MemberController.java | 3 +- .../response/MemberFindResponse.java | 8 +- .../response/MemberOauthLoginResopnse.java | 3 +- .../response/MemberTokenRefreshResponse.java | 5 +- src/main/java/notai/post/domain/Post.java | 27 ++- .../java/notai/problem/domain/Problem.java | 28 ++- .../problem/domain/ProblemRepository.java | 5 +- .../problem/query/ProblemQueryRepository.java | 42 +++++ .../result/ProblemPageContentResult.java | 8 + .../java/notai/summary/domain/Summary.java | 28 ++- .../summary/domain/SummaryRepository.java | 5 +- .../summary/query/SummaryQueryRepository.java | 42 +++++ .../result/SummaryPageContentResult.java | 8 + .../llm/application/LLMQueryServiceTest.java | 165 ++++++++++++++++++ .../notai/llm/application/LLMServiceTest.java | 126 +++++++++++++ 61 files changed, 1090 insertions(+), 341 deletions(-) delete mode 100644 src/main/java/notai/aiTask/application/AITaskService.java delete mode 100644 src/main/java/notai/aiTask/application/command/AITaskCommand.java delete mode 100644 src/main/java/notai/aiTask/domain/AITaskRepository.java delete mode 100644 src/main/java/notai/aiTask/presentation/AITaskController.java delete mode 100644 src/main/java/notai/aiTask/presentation/request/AITaskRequest.java delete mode 100644 src/main/java/notai/aiTask/presentation/response/AITaskResponse.java create mode 100644 src/main/java/notai/llm/application/LLMQueryService.java create mode 100644 src/main/java/notai/llm/application/LLMService.java create mode 100644 src/main/java/notai/llm/application/command/LLMSubmitCommand.java create mode 100644 src/main/java/notai/llm/application/command/SummaryAndProblemUpdateCommand.java create mode 100644 src/main/java/notai/llm/application/result/LLMResultsResult.java create mode 100644 src/main/java/notai/llm/application/result/LLMStatusResult.java create mode 100644 src/main/java/notai/llm/application/result/LLMSubmitResult.java rename src/main/java/notai/{aiTask/domain/AITask.java => llm/domain/LLM.java} (57%) create mode 100644 src/main/java/notai/llm/domain/LLMRepository.java rename src/main/java/notai/{aiTask => llm}/domain/TaskStatus.java (71%) create mode 100644 src/main/java/notai/llm/presentation/LLMController.java create mode 100644 src/main/java/notai/llm/presentation/request/LLMSubmitRequest.java create mode 100644 src/main/java/notai/llm/presentation/request/SummaryAndProblemUpdateRequest.java create mode 100644 src/main/java/notai/llm/presentation/response/LLMResultsResponse.java create mode 100644 src/main/java/notai/llm/presentation/response/LLMStatusResponse.java create mode 100644 src/main/java/notai/llm/presentation/response/LLMSubmitResponse.java create mode 100644 src/main/java/notai/llm/presentation/response/SummaryAndProblemUpdateResponse.java create mode 100644 src/main/java/notai/llm/query/LLMQueryRepository.java create mode 100644 src/main/java/notai/problem/query/ProblemQueryRepository.java create mode 100644 src/main/java/notai/problem/query/result/ProblemPageContentResult.java create mode 100644 src/main/java/notai/summary/query/SummaryQueryRepository.java create mode 100644 src/main/java/notai/summary/query/result/SummaryPageContentResult.java create mode 100644 src/test/java/notai/llm/application/LLMQueryServiceTest.java create mode 100644 src/test/java/notai/llm/application/LLMServiceTest.java diff --git a/.gitignore b/.gitignore index 7147c1c..9d9df52 100644 --- a/.gitignore +++ b/.gitignore @@ -198,3 +198,5 @@ gradle-app.setting !/src/main/resources/application.yml !/src/main/resources/application-local.yml !/src/main/resources/logback-spring.xml + +.idea/ diff --git a/src/main/java/notai/aiTask/application/AITaskService.java b/src/main/java/notai/aiTask/application/AITaskService.java deleted file mode 100644 index e076f3e..0000000 --- a/src/main/java/notai/aiTask/application/AITaskService.java +++ /dev/null @@ -1,38 +0,0 @@ -package notai.aiTask.application; - -import java.time.LocalDateTime; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import notai.aiTask.application.command.AITaskCommand; -import notai.aiTask.domain.AITaskRepository; -import notai.aiTask.presentation.response.AITaskResponse; -import org.springframework.stereotype.Service; - -/** - * SummaryService 와 ExamService 는 엔티티와 관련된 로직만 처리하고 - * AI 요약 및 문제 생성 요청은 여기서 처리하는 식으로 생각했습니다. - * AI 서버와의 통신은 별도 클래스에서 처리합니다. - */ -@Service -@RequiredArgsConstructor -public class AITaskService { - - private final AITaskRepository aiTaskRepository; - - /** - * 흐름 파악용 임시 메서드 - */ - public AITaskResponse submitTask(AITaskCommand command) { - command.pages().forEach(page -> { - UUID taskId = sendRequestToAIServer(); - // TODO: command 데이터를 이용해 content 만 null 인 Summary, Problem 생성 - // TODO: Summary, Problem 과 매핑된 AITask 생성 -> 작업 상태는 모두 PENDING - }); - - return AITaskResponse.of(command.documentId(), LocalDateTime.now()); - } - - private UUID sendRequestToAIServer() { - return UUID.randomUUID(); // 임시 값, 실제 구현에선 AI 서버에서 UUID 가 반환됨. - } -} diff --git a/src/main/java/notai/aiTask/application/command/AITaskCommand.java b/src/main/java/notai/aiTask/application/command/AITaskCommand.java deleted file mode 100644 index 34860c4..0000000 --- a/src/main/java/notai/aiTask/application/command/AITaskCommand.java +++ /dev/null @@ -1,10 +0,0 @@ -package notai.aiTask.application.command; - -import java.util.List; - -public record AITaskCommand( - Long documentId, - List pages -) { - -} diff --git a/src/main/java/notai/aiTask/domain/AITaskRepository.java b/src/main/java/notai/aiTask/domain/AITaskRepository.java deleted file mode 100644 index 39d14ae..0000000 --- a/src/main/java/notai/aiTask/domain/AITaskRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package notai.aiTask.domain; - -import org.springframework.data.jpa.repository.JpaRepository; - -public interface AITaskRepository extends JpaRepository { - -} diff --git a/src/main/java/notai/aiTask/presentation/AITaskController.java b/src/main/java/notai/aiTask/presentation/AITaskController.java deleted file mode 100644 index b43cd3a..0000000 --- a/src/main/java/notai/aiTask/presentation/AITaskController.java +++ /dev/null @@ -1,28 +0,0 @@ -package notai.aiTask.presentation; - -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import notai.aiTask.application.AITaskService; -import notai.aiTask.application.command.AITaskCommand; -import notai.aiTask.presentation.request.AITaskRequest; -import notai.aiTask.presentation.response.AITaskResponse; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/ai/tasks") -@RequiredArgsConstructor -public class AITaskController { - - private final AITaskService aiTaskService; - - @PostMapping - public ResponseEntity submitTask(@RequestBody @Valid AITaskRequest request) { - AITaskCommand command = request.toCommand(); - AITaskResponse response = aiTaskService.submitTask(command); - return ResponseEntity.accepted().body(response); - } -} diff --git a/src/main/java/notai/aiTask/presentation/request/AITaskRequest.java b/src/main/java/notai/aiTask/presentation/request/AITaskRequest.java deleted file mode 100644 index 50768b7..0000000 --- a/src/main/java/notai/aiTask/presentation/request/AITaskRequest.java +++ /dev/null @@ -1,18 +0,0 @@ -package notai.aiTask.presentation.request; - -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; -import java.util.List; -import notai.aiTask.application.command.AITaskCommand; - -public record AITaskRequest( - - @NotNull(message = "문서 ID는 필수 입력 값입니다.") - Long documentId, - - List<@Positive(message = "페이지 번호는 양수여야 합니다.") Integer> pages -) { - public AITaskCommand toCommand() { - return new AITaskCommand(documentId, pages); - } -} diff --git a/src/main/java/notai/aiTask/presentation/response/AITaskResponse.java b/src/main/java/notai/aiTask/presentation/response/AITaskResponse.java deleted file mode 100644 index c990a99..0000000 --- a/src/main/java/notai/aiTask/presentation/response/AITaskResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package notai.aiTask.presentation.response; - -import java.time.LocalDateTime; - -public record AITaskResponse( - Long documentId, - LocalDateTime createdAt -) { - public static AITaskResponse of(Long documentId, LocalDateTime createdAt) { - return new AITaskResponse(documentId, createdAt); - } -} diff --git a/src/main/java/notai/auth/Auth.java b/src/main/java/notai/auth/Auth.java index d3eedd0..62c5c00 100644 --- a/src/main/java/notai/auth/Auth.java +++ b/src/main/java/notai/auth/Auth.java @@ -1,13 +1,12 @@ package notai.auth; import io.swagger.v3.oas.annotations.Hidden; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; import java.lang.annotation.Retention; import java.lang.annotation.Target; -import static java.lang.annotation.ElementType.PARAMETER; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - @Hidden @Target(PARAMETER) @Retention(RUNTIME) diff --git a/src/main/java/notai/auth/AuthArgumentResolver.java b/src/main/java/notai/auth/AuthArgumentResolver.java index 6f951a8..4065082 100644 --- a/src/main/java/notai/auth/AuthArgumentResolver.java +++ b/src/main/java/notai/auth/AuthArgumentResolver.java @@ -19,8 +19,7 @@ public class AuthArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(Auth.class) - && parameter.getParameterType().equals(Long.class); + return parameter.hasParameterAnnotation(Auth.class) && parameter.getParameterType().equals(Long.class); } @Override diff --git a/src/main/java/notai/client/HttpInterfaceUtil.java b/src/main/java/notai/client/HttpInterfaceUtil.java index a2467df..bb93785 100644 --- a/src/main/java/notai/client/HttpInterfaceUtil.java +++ b/src/main/java/notai/client/HttpInterfaceUtil.java @@ -5,9 +5,9 @@ import org.springframework.web.service.invoker.HttpServiceProxyFactory; public class HttpInterfaceUtil { - public static T createHttpInterface(RestClient restClient, Class clazz) { - HttpServiceProxyFactory build = HttpServiceProxyFactory - .builderFor(RestClientAdapter.create(restClient)).build(); - return build.createClient(clazz); - } + public static T createHttpInterface(RestClient restClient, Class clazz) { + HttpServiceProxyFactory build = + HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient)).build(); + return build.createClient(clazz); + } } diff --git a/src/main/java/notai/client/oauth/OauthClient.java b/src/main/java/notai/client/oauth/OauthClient.java index bb6aeba..fcae61c 100644 --- a/src/main/java/notai/client/oauth/OauthClient.java +++ b/src/main/java/notai/client/oauth/OauthClient.java @@ -5,7 +5,7 @@ public interface OauthClient { - OauthProvider oauthProvider(); + OauthProvider oauthProvider(); - Member fetchMember(String accessToken); + Member fetchMember(String accessToken); } diff --git a/src/main/java/notai/client/oauth/OauthClientComposite.java b/src/main/java/notai/client/oauth/OauthClientComposite.java index a3ae267..e4f349b 100644 --- a/src/main/java/notai/client/oauth/OauthClientComposite.java +++ b/src/main/java/notai/client/oauth/OauthClientComposite.java @@ -1,5 +1,7 @@ package notai.client.oauth; +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.toMap; import notai.common.exception.type.BadRequestException; import notai.member.domain.Member; import notai.member.domain.OauthProvider; @@ -9,25 +11,21 @@ import java.util.Optional; import java.util.Set; -import static java.util.function.Function.identity; -import static java.util.stream.Collectors.toMap; - @Component public class OauthClientComposite { - private final Map oauthClients; + private final Map oauthClients; - public OauthClientComposite(Set oauthClients) { - this.oauthClients = oauthClients.stream() - .collect(toMap(OauthClient::oauthProvider, identity())); - } + public OauthClientComposite(Set oauthClients) { + this.oauthClients = oauthClients.stream().collect(toMap(OauthClient::oauthProvider, identity())); + } - public Member fetchMember(OauthProvider oauthProvider, String accessToken) { - return oauthClients.get(oauthProvider).fetchMember(accessToken); - } + public Member fetchMember(OauthProvider oauthProvider, String accessToken) { + return oauthClients.get(oauthProvider).fetchMember(accessToken); + } - public OauthClient getOauthClient(OauthProvider oauthProvider) { - return Optional.ofNullable(oauthClients.get(oauthProvider)).orElseThrow( - () -> new BadRequestException("지원하지 않는 소셜 로그인 타입입니다.")); - } + public OauthClient getOauthClient(OauthProvider oauthProvider) { + return Optional.ofNullable(oauthClients.get(oauthProvider)).orElseThrow(() -> new BadRequestException( + "지원하지 않는 소셜 로그인 타입입니다.")); + } } diff --git a/src/main/java/notai/client/oauth/kakao/KakaoClientConfig.java b/src/main/java/notai/client/oauth/kakao/KakaoClientConfig.java index 3b95ea1..3b79924 100644 --- a/src/main/java/notai/client/oauth/kakao/KakaoClientConfig.java +++ b/src/main/java/notai/client/oauth/kakao/KakaoClientConfig.java @@ -15,13 +15,13 @@ public class KakaoClientConfig { @Bean public KakaoClient kakaoClient() { - RestClient restClient = RestClient.builder() - .defaultStatusHandler(HttpStatusCode::isError, (request, response) -> { + RestClient restClient = RestClient.builder().defaultStatusHandler(HttpStatusCode::isError, + (request, response) -> { String responseData = new String(response.getBody().readAllBytes()); log.error("카카오톡 API 오류 : {}", responseData); throw new ExternalApiException(responseData, response.getStatusCode().value()); - }) - .build(); + } + ).build(); return createHttpInterface(restClient, KakaoClient.class); } } diff --git a/src/main/java/notai/comment/domain/Comment.java b/src/main/java/notai/comment/domain/Comment.java index 2287a46..269cda6 100644 --- a/src/main/java/notai/comment/domain/Comment.java +++ b/src/main/java/notai/comment/domain/Comment.java @@ -1,21 +1,16 @@ package notai.comment.domain; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; +import jakarta.persistence.*; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; import jakarta.validation.constraints.NotNull; +import static lombok.AccessLevel.PROTECTED; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import notai.common.domain.RootEntity; import notai.member.domain.Member; - -import static jakarta.persistence.GenerationType.IDENTITY; -import static lombok.AccessLevel.PROTECTED; +import notai.post.domain.Post; @Entity @Table(name = "comment") @@ -23,29 +18,35 @@ @NoArgsConstructor(access = PROTECTED) @AllArgsConstructor public class Comment extends RootEntity { + @Id @GeneratedValue(strategy = IDENTITY) private Long id; - @ManyToOne + @NotNull + @ManyToOne(fetch = LAZY) @JoinColumn(name = "member_id") private Member member; + @NotNull - @Column - private Long postId; + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "post_id") + private Post post; + @NotNull - @Column - private Long parentCommentId; + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "parent_comment_id", referencedColumnName = "id") + private Comment parentComment; + @NotNull @Column(length = 255) private String content; + public Comment( - Member member, - Long postId, - String content + Member member, Post post, String content ) { this.member = member; - this.postId = postId; + this.post = post; this.content = content; } diff --git a/src/main/java/notai/common/config/AuthConfig.java b/src/main/java/notai/common/config/AuthConfig.java index 6e4417f..c0e8bc9 100644 --- a/src/main/java/notai/common/config/AuthConfig.java +++ b/src/main/java/notai/common/config/AuthConfig.java @@ -17,10 +17,8 @@ public class AuthConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(authInterceptor) - .addPathPatterns("/api/**") - .excludePathPatterns("/api/members/oauth/login/**") - .excludePathPatterns("/api/members/token/refresh"); + registry.addInterceptor(authInterceptor).addPathPatterns("/api/**").excludePathPatterns( + "/api/members/oauth/login/**").excludePathPatterns("/api/members/token/refresh"); } @Override diff --git a/src/main/java/notai/common/config/AuthInterceptor.java b/src/main/java/notai/common/config/AuthInterceptor.java index 87c168c..327a0f2 100644 --- a/src/main/java/notai/common/config/AuthInterceptor.java +++ b/src/main/java/notai/common/config/AuthInterceptor.java @@ -3,11 +3,10 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import notai.auth.TokenService; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; -import static org.springframework.http.HttpHeaders.AUTHORIZATION; - @Component public class AuthInterceptor implements HandlerInterceptor { private final TokenService tokenService; diff --git a/src/main/java/notai/common/config/SwaggerConfig.java b/src/main/java/notai/common/config/SwaggerConfig.java index 7ac2250..f3f77db 100644 --- a/src/main/java/notai/common/config/SwaggerConfig.java +++ b/src/main/java/notai/common/config/SwaggerConfig.java @@ -22,20 +22,15 @@ public SwaggerConfig(@Value("${server-url}") String serverUrl) { @Bean public OpenAPI openAPI() { String jwt = "JWT"; - SecurityRequirement securityRequirement = new SecurityRequirement() - .addList(jwt); - Components components = new Components() - .addSecuritySchemes(jwt, new SecurityScheme() - .name(jwt) - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .description("토큰값을 입력하여 인증을 활성화할 수 있습니다.") - .bearerFormat("JWT") - ); + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwt); + Components components = new Components().addSecuritySchemes(jwt, new SecurityScheme().name(jwt) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .description("토큰값을 입력하여 인증을 활성화할 수 있습니다.") + .bearerFormat("JWT")); Server server = new Server(); server.setUrl(serverUrl); - return new OpenAPI() - .components(new Components()) + return new OpenAPI().components(new Components()) .info(apiInfo()) .addSecurityItem(securityRequirement) .components(components) @@ -43,9 +38,6 @@ public OpenAPI openAPI() { } private Info apiInfo() { - return new Info() - .title("notai API") - .description("notai API 문서입니다.") - .version("0.0.1"); + return new Info().title("notai API").description("notai API 문서입니다.").version("0.0.1"); } } diff --git a/src/main/java/notai/common/domain/RootEntity.java b/src/main/java/notai/common/domain/RootEntity.java index c8220c2..7fcd71b 100644 --- a/src/main/java/notai/common/domain/RootEntity.java +++ b/src/main/java/notai/common/domain/RootEntity.java @@ -2,6 +2,7 @@ import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; +import static lombok.AccessLevel.PROTECTED; import lombok.Getter; import lombok.NoArgsConstructor; import org.springframework.data.annotation.CreatedDate; @@ -12,8 +13,6 @@ import java.time.LocalDateTime; import java.util.Objects; -import static lombok.AccessLevel.PROTECTED; - @Getter @NoArgsConstructor(access = PROTECTED) @EntityListeners(AuditingEntityListener.class) diff --git a/src/main/java/notai/common/exception/ExceptionControllerAdvice.java b/src/main/java/notai/common/exception/ExceptionControllerAdvice.java index a8a8b79..c01f2d4 100644 --- a/src/main/java/notai/common/exception/ExceptionControllerAdvice.java +++ b/src/main/java/notai/common/exception/ExceptionControllerAdvice.java @@ -28,13 +28,11 @@ public class ExceptionControllerAdvice extends ResponseEntityExceptionHandler { @ExceptionHandler(ApplicationException.class) ResponseEntity handleException(HttpServletRequest request, ApplicationException e) { - log.info("잘못된 요청이 들어왔습니다. uri: {} {}, 내용: {}", - request.getMethod(), request.getRequestURI(), e.getMessage()); + log.info("잘못된 요청이 들어왔습니다. uri: {} {}, 내용: {}", request.getMethod(), request.getRequestURI(), e.getMessage()); requestLogging(request); - return ResponseEntity.status(e.getCode()) - .body(new ExceptionResponse(e.getMessage())); + return ResponseEntity.status(e.getCode()).body(new ExceptionResponse(e.getMessage())); } @ExceptionHandler(Exception.class) @@ -42,30 +40,25 @@ ResponseEntity handleException(HttpServletRequest request, Ex log.error("예상하지 못한 예외가 발생했습니다. uri: {} {}, ", request.getMethod(), request.getRequestURI(), e); requestLogging(request); - return ResponseEntity.internalServerError() - .body(new ExceptionResponse(e.getMessage())); + return ResponseEntity.internalServerError().body(new ExceptionResponse(e.getMessage())); } @Override protected ResponseEntity handleNoResourceFoundException( - NoResourceFoundException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { + NoResourceFoundException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request + ) { return new ResponseEntity<>(status); } @Override protected ResponseEntity handleExceptionInternal( - Exception e, - Object body, - HttpHeaders headers, - HttpStatusCode statusCode, - WebRequest webRequest + Exception e, Object body, HttpHeaders headers, HttpStatusCode statusCode, WebRequest webRequest ) { HttpServletRequest request = ((ServletWebRequest) webRequest).getRequest(); log.error("예외가 발생했습니다. uri: {} {}, ", request.getMethod(), request.getRequestURI(), e); requestLogging(request); - return ResponseEntity.status(statusCode) - .body(new ExceptionResponse(e.getMessage())); + return ResponseEntity.status(statusCode).body(new ExceptionResponse(e.getMessage())); } diff --git a/src/main/java/notai/document/domain/Document.java b/src/main/java/notai/document/domain/Document.java index 6d7c629..8135234 100644 --- a/src/main/java/notai/document/domain/Document.java +++ b/src/main/java/notai/document/domain/Document.java @@ -1,18 +1,9 @@ package notai.document.domain; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; +import jakarta.persistence.*; +import static jakarta.persistence.GenerationType.IDENTITY; import jakarta.validation.constraints.NotNull; -import lombok.AccessLevel; +import static lombok.AccessLevel.PROTECTED; import lombok.Getter; import lombok.NoArgsConstructor; import notai.common.domain.RootEntity; @@ -21,24 +12,30 @@ @Entity @Table(name = "document") @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = PROTECTED) public class Document extends RootEntity { + @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) + @GeneratedValue(strategy = IDENTITY) private Long id; + @NotNull @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "folder_id", referencedColumnName = "id") private Folder folder; + @NotNull @Column(name = "name", length = 50) private String name; + @NotNull @Column(name = "size") private Integer size; + @NotNull @Column(name = "total_page") private Integer totalPage; + @NotNull @Enumerated(value = EnumType.STRING) @Column(name = "status") diff --git a/src/main/java/notai/document/domain/DocumentStatus.java b/src/main/java/notai/document/domain/DocumentStatus.java index e27778f..3ae523e 100644 --- a/src/main/java/notai/document/domain/DocumentStatus.java +++ b/src/main/java/notai/document/domain/DocumentStatus.java @@ -1,6 +1,5 @@ package notai.document.domain; public enum DocumentStatus { - EXISTS, - GARBAGE + EXISTS, GARBAGE } diff --git a/src/main/java/notai/folder/domain/Folder.java b/src/main/java/notai/folder/domain/Folder.java index 631a8f4..f6367e8 100644 --- a/src/main/java/notai/folder/domain/Folder.java +++ b/src/main/java/notai/folder/domain/Folder.java @@ -1,35 +1,33 @@ package notai.folder.domain; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; +import jakarta.persistence.*; +import static jakarta.persistence.GenerationType.IDENTITY; import jakarta.validation.constraints.NotNull; -import lombok.AccessLevel; +import static lombok.AccessLevel.PROTECTED; import lombok.Getter; import lombok.NoArgsConstructor; import notai.common.domain.RootEntity; +import notai.member.domain.Member; @Entity @Table(name = "folder") @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = PROTECTED) public class Folder extends RootEntity { + @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) + @GeneratedValue(strategy = IDENTITY) private Long id; - @ManyToOne + @NotNull + @ManyToOne @JoinColumn(name = "member_id", nullable = false) private Member member; + @NotNull @Column(name = "name", length = 50) private String name; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_folder_id", referencedColumnName = "id") private Folder parentFolder; diff --git a/src/main/java/notai/llm/application/LLMQueryService.java b/src/main/java/notai/llm/application/LLMQueryService.java new file mode 100644 index 0000000..2858c2a --- /dev/null +++ b/src/main/java/notai/llm/application/LLMQueryService.java @@ -0,0 +1,110 @@ +package notai.llm.application; + +import lombok.RequiredArgsConstructor; +import notai.common.exception.type.BadRequestException; +import notai.common.exception.type.InternalServerErrorException; +import notai.common.exception.type.NotFoundException; +import notai.document.domain.DocumentRepository; +import notai.llm.application.result.LLMResultsResult; +import notai.llm.application.result.LLMResultsResult.LLMContent; +import notai.llm.application.result.LLMResultsResult.LLMResult; +import notai.llm.application.result.LLMStatusResult; +import notai.llm.domain.TaskStatus; +import notai.llm.query.LLMQueryRepository; +import notai.problem.query.ProblemQueryRepository; +import notai.problem.query.result.ProblemPageContentResult; +import notai.summary.query.SummaryQueryRepository; +import notai.summary.query.result.SummaryPageContentResult; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; + +import static notai.llm.domain.TaskStatus.COMPLETED; +import static notai.llm.domain.TaskStatus.IN_PROGRESS; + +@Service +@RequiredArgsConstructor +public class LLMQueryService { + + private final LLMQueryRepository llmQueryRepository; + private final DocumentRepository documentRepository; + private final SummaryQueryRepository summaryQueryRepository; + private final ProblemQueryRepository problemQueryRepository; + + public LLMStatusResult fetchTaskStatus(Long documentId) { + checkDocumentExists(documentId); + List summaryIds = getSummaryIds(documentId); + List taskStatuses = getTaskStatuses(summaryIds); + + int totalPages = summaryIds.size(); + int completedPages = Collections.frequency(taskStatuses, COMPLETED); + + if (totalPages == completedPages) { + return LLMStatusResult.of(documentId, COMPLETED, totalPages, completedPages); + } + return LLMStatusResult.of(documentId, IN_PROGRESS, totalPages, completedPages); + } + + public LLMResultsResult findTaskResult(Long documentId) { + checkDocumentExists(documentId); + List summaryResults = getSummaryPageContentResults(documentId); + List problemResults = getProblemPageContentResults(documentId); + checkSummaryAndProblemCountsEqual(summaryResults, problemResults); + + List results = summaryResults.stream().map(summaryResult -> { + LLMContent content = LLMContent.of( + summaryResult.content(), + findProblemContentByPageNumber(problemResults, summaryResult.pageNumber()) + ); + return LLMResult.of(summaryResult.pageNumber(), content); + }).toList(); + + return LLMResultsResult.of(documentId, results); + } + + private void checkDocumentExists(Long documentId) { + if (!documentRepository.existsById(documentId)) { + throw new NotFoundException("해당 강의자료를 찾을 수 없습니다."); + } + } + + private static void checkSummaryAndProblemCountsEqual( + List summaryResults, List problemResults + ) { + if (summaryResults.size() != problemResults.size()) { + throw new InternalServerErrorException("AI 요약 및 문제 생성 중에 문제가 발생했습니다."); // 요약 개수와 문제 개수가 불일치 + } + } + + private List getSummaryIds(Long documentId) { + List summaryIds = summaryQueryRepository.getSummaryIdsByDocumentId(documentId); + if (summaryIds.isEmpty()) { + throw new BadRequestException("AI 기능을 요청한 기록이 없습니다."); + } + return summaryIds; + } + + private List getTaskStatuses(List summaryIds) { + return summaryIds.stream().map(llmQueryRepository::getTaskStatusBySummaryId).toList(); + } + + private List getSummaryPageContentResults(Long documentId) { + List summaryResults = summaryQueryRepository.getPageNumbersAndContentByDocumentId( + documentId); + if (summaryResults.isEmpty()) { + throw new NotFoundException("AI 기능을 요청한 기록이 없습니다."); + } + return summaryResults; + } + + private List getProblemPageContentResults(Long documentId) { + return problemQueryRepository.getPageNumbersAndContentByDocumentId(documentId); + } + + private String findProblemContentByPageNumber(List results, int pageNumber) { + return results.stream().filter(result -> result.pageNumber() == pageNumber).findFirst().map( + ProblemPageContentResult::content).orElseThrow(() -> new InternalServerErrorException( + "AI 요약 및 문제 생성 중에 문제가 발생했습니다.")); // 요약 페이지와 문제 페이지가 불일치 + } +} diff --git a/src/main/java/notai/llm/application/LLMService.java b/src/main/java/notai/llm/application/LLMService.java new file mode 100644 index 0000000..d6e529e --- /dev/null +++ b/src/main/java/notai/llm/application/LLMService.java @@ -0,0 +1,76 @@ +package notai.llm.application; + +import lombok.RequiredArgsConstructor; +import notai.common.exception.type.NotFoundException; +import notai.document.domain.Document; +import notai.document.domain.DocumentRepository; +import notai.llm.application.command.LLMSubmitCommand; +import notai.llm.application.command.SummaryAndProblemUpdateCommand; +import notai.llm.application.result.LLMSubmitResult; +import notai.llm.domain.LLM; +import notai.llm.domain.LLMRepository; +import notai.problem.domain.Problem; +import notai.problem.domain.ProblemRepository; +import notai.summary.domain.Summary; +import notai.summary.domain.SummaryRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * SummaryService 와 ExamService 는 엔티티와 관련된 로직만 처리하고 + * AI 요약 및 문제 생성 요청은 여기서 처리하는 식으로 생각했습니다. + * AI 서버와의 통신은 별도 클래스에서 처리합니다. + */ +@Service +@Transactional +@RequiredArgsConstructor +public class LLMService { + + private final LLMRepository llmRepository; + private final DocumentRepository documentRepository; + private final SummaryRepository summaryRepository; + private final ProblemRepository problemRepository; + + public LLMSubmitResult submitTask(LLMSubmitCommand command) { + // TODO: document 개발 코드 올려주시면, getById 로 수정 + Document foundDocument = + documentRepository.findById(command.documentId()).orElseThrow(() -> new NotFoundException("")); + + command.pages().forEach(pageNumber -> { + UUID taskId = sendRequestToAIServer(); + Summary summary = new Summary(foundDocument, pageNumber); + Problem problem = new Problem(foundDocument, pageNumber); + + LLM taskRecord = new LLM(taskId, summary, problem); + llmRepository.save(taskRecord); + }); + + return LLMSubmitResult.of(command.documentId(), LocalDateTime.now()); + } + + public Integer updateSummaryAndProblem(SummaryAndProblemUpdateCommand command) { + LLM taskRecord = llmRepository.getById(command.taskId()); + Summary foundSummary = summaryRepository.getById(taskRecord.getSummary().getId()); + Problem foundProblem = problemRepository.getById(taskRecord.getProblem().getId()); + + taskRecord.completeTask(); + foundSummary.updateContent(command.summary()); + foundProblem.updateContent(command.problem()); + + llmRepository.save(taskRecord); + summaryRepository.save(foundSummary); + problemRepository.save(foundProblem); + + return command.pageNumber(); + } + + /** + * 임시 값 반환, 추후 AI 서버에서 작업 단위 UUID 가 반환됨. + */ + private UUID sendRequestToAIServer() { + return UUID.randomUUID(); + } +} diff --git a/src/main/java/notai/llm/application/command/LLMSubmitCommand.java b/src/main/java/notai/llm/application/command/LLMSubmitCommand.java new file mode 100644 index 0000000..3e7b329 --- /dev/null +++ b/src/main/java/notai/llm/application/command/LLMSubmitCommand.java @@ -0,0 +1,10 @@ +package notai.llm.application.command; + +import java.util.List; + +public record LLMSubmitCommand( + Long documentId, + List pages +) { + +} diff --git a/src/main/java/notai/llm/application/command/SummaryAndProblemUpdateCommand.java b/src/main/java/notai/llm/application/command/SummaryAndProblemUpdateCommand.java new file mode 100644 index 0000000..420e5d4 --- /dev/null +++ b/src/main/java/notai/llm/application/command/SummaryAndProblemUpdateCommand.java @@ -0,0 +1,11 @@ +package notai.llm.application.command; + +import java.util.UUID; + +public record SummaryAndProblemUpdateCommand( + UUID taskId, + Integer pageNumber, + String summary, + String problem +) { +} diff --git a/src/main/java/notai/llm/application/result/LLMResultsResult.java b/src/main/java/notai/llm/application/result/LLMResultsResult.java new file mode 100644 index 0000000..63dfcaa --- /dev/null +++ b/src/main/java/notai/llm/application/result/LLMResultsResult.java @@ -0,0 +1,31 @@ +package notai.llm.application.result; + +import java.util.List; + +public record LLMResultsResult( + Long documentId, + Integer totalPages, + List results +) { + public static LLMResultsResult of(Long documentId, List results) { + return new LLMResultsResult(documentId, results.size(), results); + } + + public record LLMResult( + Integer pageNumber, + LLMContent content + ) { + public static LLMResult of(Integer pageNumber, LLMContent content) { + return new LLMResult(pageNumber, content); + } + } + + public record LLMContent( + String summary, + String problem + ) { + public static LLMContent of(String summary, String problem) { + return new LLMContent(summary, problem); + } + } +} diff --git a/src/main/java/notai/llm/application/result/LLMStatusResult.java b/src/main/java/notai/llm/application/result/LLMStatusResult.java new file mode 100644 index 0000000..a9a3768 --- /dev/null +++ b/src/main/java/notai/llm/application/result/LLMStatusResult.java @@ -0,0 +1,14 @@ +package notai.llm.application.result; + +import notai.llm.domain.TaskStatus; + +public record LLMStatusResult( + Long documentId, + TaskStatus overallStatus, + Integer totalPages, + Integer completedPages +) { + public static LLMStatusResult of(Long documentId, TaskStatus overallStatus, Integer totalPages, Integer completedPages) { + return new LLMStatusResult(documentId, overallStatus, totalPages, completedPages); + } +} diff --git a/src/main/java/notai/llm/application/result/LLMSubmitResult.java b/src/main/java/notai/llm/application/result/LLMSubmitResult.java new file mode 100644 index 0000000..ab0c2ac --- /dev/null +++ b/src/main/java/notai/llm/application/result/LLMSubmitResult.java @@ -0,0 +1,12 @@ +package notai.llm.application.result; + +import java.time.LocalDateTime; + +public record LLMSubmitResult( + Long documentId, + LocalDateTime createdAt +) { + public static LLMSubmitResult of(Long documentId, LocalDateTime createdAt) { + return new LLMSubmitResult(documentId, createdAt); + } +} diff --git a/src/main/java/notai/aiTask/domain/AITask.java b/src/main/java/notai/llm/domain/LLM.java similarity index 57% rename from src/main/java/notai/aiTask/domain/AITask.java rename to src/main/java/notai/llm/domain/LLM.java index 5f8f995..f93c53f 100644 --- a/src/main/java/notai/aiTask/domain/AITask.java +++ b/src/main/java/notai/llm/domain/LLM.java @@ -1,40 +1,37 @@ -package notai.aiTask.domain; +package notai.llm.domain; -import static lombok.AccessLevel.PROTECTED; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; +import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; -import java.util.UUID; import lombok.Getter; import lombok.NoArgsConstructor; import notai.common.domain.RootEntity; import notai.problem.domain.Problem; import notai.summary.domain.Summary; +import java.util.UUID; + +import static lombok.AccessLevel.PROTECTED; + + +/** + * 요약과 문제 생성을 하는 LLM 모델의 작업 기록을 저장하는 테이블입니다. + */ @Getter @NoArgsConstructor(access = PROTECTED) @Entity -@Table(name = "ai_task") -public class AITask extends RootEntity { +@Table(name = "llm") +public class LLM extends RootEntity { @Id private UUID id; @NotNull - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) @JoinColumn(name = "summary_id") private Summary summary; @NotNull - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) @JoinColumn(name = "problem_id") private Problem problem; @@ -43,10 +40,14 @@ public class AITask extends RootEntity { @Column(length = 20) private TaskStatus status; - public AITask(UUID id, Summary summary, Problem problem) { + public LLM(UUID id, Summary summary, Problem problem) { this.id = id; this.summary = summary; this.problem = problem; this.status = TaskStatus.PENDING; } + + public void completeTask() { + this.status = TaskStatus.COMPLETED; + } } diff --git a/src/main/java/notai/llm/domain/LLMRepository.java b/src/main/java/notai/llm/domain/LLMRepository.java new file mode 100644 index 0000000..c9bfa6c --- /dev/null +++ b/src/main/java/notai/llm/domain/LLMRepository.java @@ -0,0 +1,12 @@ +package notai.llm.domain; + +import notai.common.exception.type.NotFoundException; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface LLMRepository extends JpaRepository { + default LLM getById(UUID id) { + return findById(id).orElseThrow(() -> new NotFoundException("해당 작업 기록을 찾을 수 없습니다.")); + } +} diff --git a/src/main/java/notai/aiTask/domain/TaskStatus.java b/src/main/java/notai/llm/domain/TaskStatus.java similarity index 71% rename from src/main/java/notai/aiTask/domain/TaskStatus.java rename to src/main/java/notai/llm/domain/TaskStatus.java index 3209e9f..be44ed8 100644 --- a/src/main/java/notai/aiTask/domain/TaskStatus.java +++ b/src/main/java/notai/llm/domain/TaskStatus.java @@ -1,4 +1,4 @@ -package notai.aiTask.domain; +package notai.llm.domain; public enum TaskStatus { PENDING, diff --git a/src/main/java/notai/llm/presentation/LLMController.java b/src/main/java/notai/llm/presentation/LLMController.java new file mode 100644 index 0000000..69ad4ff --- /dev/null +++ b/src/main/java/notai/llm/presentation/LLMController.java @@ -0,0 +1,56 @@ +package notai.llm.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import notai.llm.application.LLMQueryService; +import notai.llm.application.LLMService; +import notai.llm.application.command.LLMSubmitCommand; +import notai.llm.application.command.SummaryAndProblemUpdateCommand; +import notai.llm.application.result.LLMResultsResult; +import notai.llm.application.result.LLMStatusResult; +import notai.llm.application.result.LLMSubmitResult; +import notai.llm.presentation.request.LLMSubmitRequest; +import notai.llm.presentation.request.SummaryAndProblemUpdateRequest; +import notai.llm.presentation.response.LLMResultsResponse; +import notai.llm.presentation.response.LLMStatusResponse; +import notai.llm.presentation.response.LLMSubmitResponse; +import notai.llm.presentation.response.SummaryAndProblemUpdateResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/ai/llm") +@RequiredArgsConstructor +public class LLMController { + + private final LLMService llmService; + private final LLMQueryService llmQueryService; + + @PostMapping + public ResponseEntity submitTask(@RequestBody @Valid LLMSubmitRequest request) { + LLMSubmitCommand command = request.toCommand(); + LLMSubmitResult result = llmService.submitTask(command); + return ResponseEntity.accepted().body(LLMSubmitResponse.from(result)); + } + + @GetMapping("/status/{documentId}") + public ResponseEntity fetchTaskStatus(@PathVariable("documentId") Long documentId) { + LLMStatusResult result = llmQueryService.fetchTaskStatus(documentId); + return ResponseEntity.ok(LLMStatusResponse.from(result)); + } + + @GetMapping("/results/{documentId}") + public ResponseEntity findTaskResult(@PathVariable("documentId") Long documentId) { + LLMResultsResult result = llmQueryService.findTaskResult(documentId); + return ResponseEntity.ok(LLMResultsResponse.of(result)); + } + + @PostMapping("/callback") + public ResponseEntity handleTaskCallback( + @RequestBody @Valid SummaryAndProblemUpdateRequest request + ) { + SummaryAndProblemUpdateCommand command = request.toCommand(); + Integer receivedPage = llmService.updateSummaryAndProblem(command); + return ResponseEntity.ok(SummaryAndProblemUpdateResponse.from(receivedPage)); + } +} diff --git a/src/main/java/notai/llm/presentation/request/LLMSubmitRequest.java b/src/main/java/notai/llm/presentation/request/LLMSubmitRequest.java new file mode 100644 index 0000000..8f78f1d --- /dev/null +++ b/src/main/java/notai/llm/presentation/request/LLMSubmitRequest.java @@ -0,0 +1,19 @@ +package notai.llm.presentation.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import notai.llm.application.command.LLMSubmitCommand; + +import java.util.List; + +public record LLMSubmitRequest( + + @NotNull(message = "문서 ID는 필수 입력 값입니다.") + Long documentId, + + List<@Positive(message = "페이지 번호는 양수여야 합니다.") Integer> pages +) { + public LLMSubmitCommand toCommand() { + return new LLMSubmitCommand(documentId, pages); + } +} diff --git a/src/main/java/notai/llm/presentation/request/SummaryAndProblemUpdateRequest.java b/src/main/java/notai/llm/presentation/request/SummaryAndProblemUpdateRequest.java new file mode 100644 index 0000000..1eba697 --- /dev/null +++ b/src/main/java/notai/llm/presentation/request/SummaryAndProblemUpdateRequest.java @@ -0,0 +1,26 @@ +package notai.llm.presentation.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import notai.llm.application.command.SummaryAndProblemUpdateCommand; + +import java.util.UUID; + +public record SummaryAndProblemUpdateRequest( + UUID taskId, + + @NotNull Long documentId, + + Integer totalPages, + + @NotNull @Positive Integer pageNumber, + + @NotBlank String summary, + + @NotBlank String problem +) { + public SummaryAndProblemUpdateCommand toCommand() { + return new SummaryAndProblemUpdateCommand(taskId, pageNumber, summary, problem); + } +} diff --git a/src/main/java/notai/llm/presentation/response/LLMResultsResponse.java b/src/main/java/notai/llm/presentation/response/LLMResultsResponse.java new file mode 100644 index 0000000..535a1f2 --- /dev/null +++ b/src/main/java/notai/llm/presentation/response/LLMResultsResponse.java @@ -0,0 +1,39 @@ +package notai.llm.presentation.response; + +import notai.llm.application.result.LLMResultsResult; +import notai.llm.application.result.LLMResultsResult.LLMContent; +import notai.llm.application.result.LLMResultsResult.LLMResult; + +import java.util.List; + +public record LLMResultsResponse( + Long documentId, + Integer totalPages, + List results +) { + public static LLMResultsResponse of(LLMResultsResult result) { + return new LLMResultsResponse( + result.documentId(), + result.results().size(), + result.results().stream().map(Result::of).toList() + ); + } + + public record Result( + Integer pageNumber, + Content content + ) { + public static Result of(LLMResult result) { + return new Result(result.pageNumber(), Content.of(result.content())); + } + } + + public record Content( + String summary, + String problem + ) { + public static Content of(LLMContent result) { + return new Content(result.summary(), result.problem()); + } + } +} diff --git a/src/main/java/notai/llm/presentation/response/LLMStatusResponse.java b/src/main/java/notai/llm/presentation/response/LLMStatusResponse.java new file mode 100644 index 0000000..9981105 --- /dev/null +++ b/src/main/java/notai/llm/presentation/response/LLMStatusResponse.java @@ -0,0 +1,20 @@ +package notai.llm.presentation.response; + +import notai.llm.application.result.LLMStatusResult; +import notai.llm.domain.TaskStatus; + +public record LLMStatusResponse( + Long documentId, + TaskStatus overallStatus, + Integer totalPages, + Integer completedPages +) { + public static LLMStatusResponse from(LLMStatusResult result) { + return new LLMStatusResponse( + result.documentId(), + result.overallStatus(), + result.totalPages(), + result.completedPages() + ); + } +} diff --git a/src/main/java/notai/llm/presentation/response/LLMSubmitResponse.java b/src/main/java/notai/llm/presentation/response/LLMSubmitResponse.java new file mode 100644 index 0000000..f63240e --- /dev/null +++ b/src/main/java/notai/llm/presentation/response/LLMSubmitResponse.java @@ -0,0 +1,14 @@ +package notai.llm.presentation.response; + +import notai.llm.application.result.LLMSubmitResult; + +import java.time.LocalDateTime; + +public record LLMSubmitResponse( + Long documentId, + LocalDateTime createdAt +) { + public static LLMSubmitResponse from(LLMSubmitResult result) { + return new LLMSubmitResponse(result.documentId(), result.createdAt()); + } +} diff --git a/src/main/java/notai/llm/presentation/response/SummaryAndProblemUpdateResponse.java b/src/main/java/notai/llm/presentation/response/SummaryAndProblemUpdateResponse.java new file mode 100644 index 0000000..33d2edc --- /dev/null +++ b/src/main/java/notai/llm/presentation/response/SummaryAndProblemUpdateResponse.java @@ -0,0 +1,9 @@ +package notai.llm.presentation.response; + +public record SummaryAndProblemUpdateResponse( + Integer receivedPage +) { + public static SummaryAndProblemUpdateResponse from(Integer receivedPage) { + return new SummaryAndProblemUpdateResponse(receivedPage); + } +} diff --git a/src/main/java/notai/llm/query/LLMQueryRepository.java b/src/main/java/notai/llm/query/LLMQueryRepository.java new file mode 100644 index 0000000..d7c1bb0 --- /dev/null +++ b/src/main/java/notai/llm/query/LLMQueryRepository.java @@ -0,0 +1,24 @@ +package notai.llm.query; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import notai.llm.domain.QLLM; +import notai.llm.domain.TaskStatus; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class LLMQueryRepository { + + private final JPAQueryFactory queryFactory; + + public TaskStatus getTaskStatusBySummaryId(Long summaryId) { + QLLM lLM = QLLM.lLM; + + return queryFactory + .select(lLM.status) + .from(lLM) + .where(lLM.summary.id.eq(summaryId)) + .fetchOne(); + } +} diff --git a/src/main/java/notai/member/application/MemberService.java b/src/main/java/notai/member/application/MemberService.java index 79ffbc8..df6a63f 100644 --- a/src/main/java/notai/member/application/MemberService.java +++ b/src/main/java/notai/member/application/MemberService.java @@ -16,6 +16,7 @@ public class MemberService { public Long login(Member member) { return memberRepository.findByOauthId(member.getOauthId()) .orElseGet(() -> memberRepository.save(member)) - .getId(); + .getId() + .longValue(); } } diff --git a/src/main/java/notai/member/application/result/MemberFindResult.java b/src/main/java/notai/member/application/result/MemberFindResult.java index b8dd7c9..261e83e 100644 --- a/src/main/java/notai/member/application/result/MemberFindResult.java +++ b/src/main/java/notai/member/application/result/MemberFindResult.java @@ -3,13 +3,9 @@ import notai.member.domain.Member; public record MemberFindResult( - Long id, - String nickname + Long id, String nickname ) { public static MemberFindResult from(Member member) { - return new MemberFindResult( - member.getId(), - member.getNickname() - ); + return new MemberFindResult(member.getId(), member.getNickname()); } } diff --git a/src/main/java/notai/member/domain/Member.java b/src/main/java/notai/member/domain/Member.java index cae89ea..2cbb807 100644 --- a/src/main/java/notai/member/domain/Member.java +++ b/src/main/java/notai/member/domain/Member.java @@ -1,14 +1,14 @@ package notai.member.domain; import jakarta.persistence.*; +import static jakarta.persistence.GenerationType.IDENTITY; +import jakarta.validation.constraints.NotNull; +import static lombok.AccessLevel.PROTECTED; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import notai.common.domain.RootEntity; -import static jakarta.persistence.GenerationType.IDENTITY; -import static lombok.AccessLevel.PROTECTED; - @Entity @Table(name = "member") @Getter @@ -16,29 +16,32 @@ @AllArgsConstructor public class Member extends RootEntity { - @Id - @GeneratedValue(strategy = IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; - @Embedded - private OauthId oauthId; + @Embedded + private OauthId oauthId; - @Column(length = 50, nullable = false) - private String email; + @NotNull + @Column(length = 50) + private String email; - @Column(length = 20, nullable = true) - private String nickname; + @NotNull + @Column(length = 20) + private String nickname; - @Column(length = 255, nullable = false) - private String refreshToken; + @NotNull + @Column(length = 255) + private String refreshToken; - public Member(OauthId oauthId, String email, String nickname) { - this.oauthId = oauthId; - this.email = email; - this.nickname = nickname; - } + public Member(OauthId oauthId, String email, String nickname) { + this.oauthId = oauthId; + this.email = email; + this.nickname = nickname; + } - public void updateRefreshToken(String refreshToken) { - this.refreshToken = refreshToken; - } + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } } diff --git a/src/main/java/notai/member/domain/MemberRepository.java b/src/main/java/notai/member/domain/MemberRepository.java index 548ae9b..e3a8ee6 100644 --- a/src/main/java/notai/member/domain/MemberRepository.java +++ b/src/main/java/notai/member/domain/MemberRepository.java @@ -10,15 +10,12 @@ public interface MemberRepository extends JpaRepository { Optional findByOauthId(OauthId oauthId); default Member getById(Long id) { - return findById(id).orElseThrow(() -> - new NotFoundException("회원 정보를 찾을 수 없습니다.") - ); + return findById(id).orElseThrow(() -> new NotFoundException("회원 정보를 찾을 수 없습니다.")); } Optional findByRefreshToken(String refreshToken); default Member getByRefreshToken(String refreshToken) { - return findByRefreshToken(refreshToken) - .orElseThrow(() -> new UnAuthorizedException("유효하지 않은 Refresh Token입니다.")); + return findByRefreshToken(refreshToken).orElseThrow(() -> new UnAuthorizedException("유효하지 않은 Refresh Token입니다.")); } } diff --git a/src/main/java/notai/member/domain/OauthId.java b/src/main/java/notai/member/domain/OauthId.java index 0d3bbc4..b1765fd 100644 --- a/src/main/java/notai/member/domain/OauthId.java +++ b/src/main/java/notai/member/domain/OauthId.java @@ -2,24 +2,26 @@ import jakarta.persistence.Column; import jakarta.persistence.Embeddable; +import static jakarta.persistence.EnumType.STRING; import jakarta.persistence.Enumerated; +import jakarta.validation.constraints.NotNull; +import static lombok.AccessLevel.PROTECTED; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; -import static jakarta.persistence.EnumType.STRING; -import static lombok.AccessLevel.PROTECTED; - @Getter @Embeddable @AllArgsConstructor @NoArgsConstructor(access = PROTECTED) public class OauthId { - @Column(length = 255, nullable = false) - private String oauthId; + @NotNull + @Column(length = 255) + private String oauthId; - @Enumerated(STRING) - @Column(length = 20, nullable = false) - private OauthProvider oauthProvider; + @NotNull + @Enumerated(STRING) + @Column(length = 20) + private OauthProvider oauthProvider; } diff --git a/src/main/java/notai/member/domain/OauthProvider.java b/src/main/java/notai/member/domain/OauthProvider.java index ef90806..a3b0a4f 100644 --- a/src/main/java/notai/member/domain/OauthProvider.java +++ b/src/main/java/notai/member/domain/OauthProvider.java @@ -1,5 +1,5 @@ package notai.member.domain; public enum OauthProvider { - KAKAO + KAKAO } diff --git a/src/main/java/notai/member/presentation/MemberController.java b/src/main/java/notai/member/presentation/MemberController.java index 1da6230..2843b03 100644 --- a/src/main/java/notai/member/presentation/MemberController.java +++ b/src/main/java/notai/member/presentation/MemberController.java @@ -29,8 +29,7 @@ public class MemberController { @PostMapping("/oauth/login/{oauthProvider}") public ResponseEntity loginWithOauth( - @PathVariable(value = "oauthProvider") OauthProvider oauthProvider, - @RequestBody OauthLoginRequest request + @PathVariable(value = "oauthProvider") OauthProvider oauthProvider, @RequestBody OauthLoginRequest request ) { Member member = oauthClient.fetchMember(oauthProvider, request.oauthAccessToken()); Long memberId = memberService.login(member); diff --git a/src/main/java/notai/member/presentation/response/MemberFindResponse.java b/src/main/java/notai/member/presentation/response/MemberFindResponse.java index 559a7c6..a1b8115 100644 --- a/src/main/java/notai/member/presentation/response/MemberFindResponse.java +++ b/src/main/java/notai/member/presentation/response/MemberFindResponse.java @@ -3,13 +3,9 @@ import notai.member.application.result.MemberFindResult; public record MemberFindResponse( - Long id, - String nickname + Long id, String nickname ) { public static MemberFindResponse from(MemberFindResult result) { - return new MemberFindResponse( - result.id(), - result.nickname() - ); + return new MemberFindResponse(result.id(), result.nickname()); } } diff --git a/src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java b/src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java index 118397d..7655d6d 100644 --- a/src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java +++ b/src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java @@ -3,8 +3,7 @@ import notai.auth.TokenPair; public record MemberOauthLoginResopnse( - String accessToken, - String refreshToken + String accessToken, String refreshToken ) { public static MemberOauthLoginResopnse from(TokenPair tokenPair) { return new MemberOauthLoginResopnse(tokenPair.accessToken(), tokenPair.refreshToken()); diff --git a/src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java b/src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java index 612aed9..b10b135 100644 --- a/src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java +++ b/src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java @@ -3,9 +3,8 @@ import notai.auth.TokenPair; public record MemberTokenRefreshResponse( - String accessToken, - String refreshToken -) { + String accessToken, String refreshToken +) { public static MemberTokenRefreshResponse from(TokenPair tokenPair) { return new MemberTokenRefreshResponse(tokenPair.accessToken(), tokenPair.refreshToken()); } diff --git a/src/main/java/notai/post/domain/Post.java b/src/main/java/notai/post/domain/Post.java index 74c4d44..9202ab6 100644 --- a/src/main/java/notai/post/domain/Post.java +++ b/src/main/java/notai/post/domain/Post.java @@ -1,44 +1,39 @@ package notai.post.domain; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; +import jakarta.persistence.*; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; import jakarta.validation.constraints.NotNull; +import static lombok.AccessLevel.PROTECTED; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import notai.member.domain.Member; -import static jakarta.persistence.GenerationType.IDENTITY; -import static lombok.AccessLevel.PROTECTED; - @Getter @NoArgsConstructor(access = PROTECTED) @AllArgsConstructor @Entity public class Post { + @Id @GeneratedValue(strategy = IDENTITY) private Long id; - @ManyToOne + @NotNull - @JoinColumn(name="member_id") + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "member_id") private Member member; + @NotNull @Column(length = 255) private String title; + @NotNull @Column(length = 255) private String contents; - public Post( - Member member, - String title, - String contents - ) { + public Post(Member member, String title, String contents) { this.member = member; this.title = title; this.contents = contents; diff --git a/src/main/java/notai/problem/domain/Problem.java b/src/main/java/notai/problem/domain/Problem.java index b2c6466..0b28a63 100644 --- a/src/main/java/notai/problem/domain/Problem.java +++ b/src/main/java/notai/problem/domain/Problem.java @@ -1,16 +1,15 @@ package notai.problem.domain; -import static lombok.AccessLevel.PROTECTED; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; +import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import lombok.Getter; import lombok.NoArgsConstructor; import notai.common.domain.RootEntity; +import notai.document.domain.Document; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; @Getter @NoArgsConstructor(access = PROTECTED) @@ -18,15 +17,26 @@ public class Problem extends RootEntity { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) + @GeneratedValue(strategy = IDENTITY) private Long id; @NotNull - private Long documentId; + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "document_id") + private Document document; @NotNull private Integer pageNumber; @Column(columnDefinition = "TEXT") private String content; + + public Problem(Document document, Integer pageNumber) { + this.document = document; + this.pageNumber = pageNumber; + } + + public void updateContent(String content) { + this.content = content; + } } diff --git a/src/main/java/notai/problem/domain/ProblemRepository.java b/src/main/java/notai/problem/domain/ProblemRepository.java index da3b9bc..d5f558d 100644 --- a/src/main/java/notai/problem/domain/ProblemRepository.java +++ b/src/main/java/notai/problem/domain/ProblemRepository.java @@ -1,7 +1,10 @@ package notai.problem.domain; +import notai.common.exception.type.NotFoundException; import org.springframework.data.jpa.repository.JpaRepository; public interface ProblemRepository extends JpaRepository { - + default Problem getById(Long id) { + return findById(id).orElseThrow(() -> new NotFoundException("해당 문제 정보를 찾을 수 없습니다.")); + } } diff --git a/src/main/java/notai/problem/query/ProblemQueryRepository.java b/src/main/java/notai/problem/query/ProblemQueryRepository.java new file mode 100644 index 0000000..615c321 --- /dev/null +++ b/src/main/java/notai/problem/query/ProblemQueryRepository.java @@ -0,0 +1,42 @@ +package notai.problem.query; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import notai.problem.domain.QProblem; +import notai.problem.query.result.ProblemPageContentResult; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class ProblemQueryRepository { + + private final JPAQueryFactory queryFactory; + + public List getProblemIdsByDocumentId(Long documentId) { + QProblem problem = QProblem.problem; + + return queryFactory + .select(problem.id) + .from(problem) + .where(problem.document.id.eq(documentId)) + .fetch(); + } + + public List getPageNumbersAndContentByDocumentId(Long documentId) { + QProblem problem = QProblem.problem; + + return queryFactory + .select(Projections.constructor( + ProblemPageContentResult.class, + problem.pageNumber, + problem.content + )) + .from(problem) + .where(problem.document.id.eq(documentId) + .and(problem.content.isNotNull())) + .fetch(); + } +} diff --git a/src/main/java/notai/problem/query/result/ProblemPageContentResult.java b/src/main/java/notai/problem/query/result/ProblemPageContentResult.java new file mode 100644 index 0000000..675b22b --- /dev/null +++ b/src/main/java/notai/problem/query/result/ProblemPageContentResult.java @@ -0,0 +1,8 @@ +package notai.problem.query.result; + +public record ProblemPageContentResult( + Integer pageNumber, + String content +) { + +} diff --git a/src/main/java/notai/summary/domain/Summary.java b/src/main/java/notai/summary/domain/Summary.java index edf773d..9882179 100644 --- a/src/main/java/notai/summary/domain/Summary.java +++ b/src/main/java/notai/summary/domain/Summary.java @@ -1,16 +1,15 @@ package notai.summary.domain; -import static lombok.AccessLevel.PROTECTED; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; +import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import lombok.Getter; import lombok.NoArgsConstructor; import notai.common.domain.RootEntity; +import notai.document.domain.Document; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; @Getter @NoArgsConstructor(access = PROTECTED) @@ -18,15 +17,26 @@ public class Summary extends RootEntity { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) + @GeneratedValue(strategy = IDENTITY) private Long id; @NotNull - private Long documentId; + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "document_id") + private Document document; @NotNull private Integer pageNumber; @Column(columnDefinition = "TEXT") private String content; + + public Summary(Document document, Integer pageNumber) { + this.document = document; + this.pageNumber = pageNumber; + } + + public void updateContent(String content) { + this.content = content; + } } diff --git a/src/main/java/notai/summary/domain/SummaryRepository.java b/src/main/java/notai/summary/domain/SummaryRepository.java index 63085d9..45d6b5d 100644 --- a/src/main/java/notai/summary/domain/SummaryRepository.java +++ b/src/main/java/notai/summary/domain/SummaryRepository.java @@ -1,7 +1,10 @@ package notai.summary.domain; +import notai.common.exception.type.NotFoundException; import org.springframework.data.jpa.repository.JpaRepository; public interface SummaryRepository extends JpaRepository { - + default Summary getById(Long id) { + return findById(id).orElseThrow(() -> new NotFoundException("해당 요약 정보를 찾을 수 없습니다.")); + } } diff --git a/src/main/java/notai/summary/query/SummaryQueryRepository.java b/src/main/java/notai/summary/query/SummaryQueryRepository.java new file mode 100644 index 0000000..b767161 --- /dev/null +++ b/src/main/java/notai/summary/query/SummaryQueryRepository.java @@ -0,0 +1,42 @@ +package notai.summary.query; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import notai.summary.query.result.SummaryPageContentResult; +import notai.summary.domain.QSummary; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class SummaryQueryRepository { + + private final JPAQueryFactory queryFactory; + + public List getSummaryIdsByDocumentId(Long documentId) { + QSummary summary = QSummary.summary; + + return queryFactory + .select(summary.id) + .from(summary) + .where(summary.document.id.eq(documentId)) + .fetch(); + } + + public List getPageNumbersAndContentByDocumentId(Long documentId) { + QSummary summary = QSummary.summary; + + return queryFactory + .select(Projections.constructor( + SummaryPageContentResult.class, + summary.pageNumber, + summary.content + )) + .from(summary) + .where(summary.document.id.eq(documentId) + .and(summary.content.isNotNull())) + .fetch(); + } +} diff --git a/src/main/java/notai/summary/query/result/SummaryPageContentResult.java b/src/main/java/notai/summary/query/result/SummaryPageContentResult.java new file mode 100644 index 0000000..aa1e5f1 --- /dev/null +++ b/src/main/java/notai/summary/query/result/SummaryPageContentResult.java @@ -0,0 +1,8 @@ +package notai.summary.query.result; + +public record SummaryPageContentResult( + Integer pageNumber, + String content +) { + +} diff --git a/src/test/java/notai/llm/application/LLMQueryServiceTest.java b/src/test/java/notai/llm/application/LLMQueryServiceTest.java new file mode 100644 index 0000000..c1d9372 --- /dev/null +++ b/src/test/java/notai/llm/application/LLMQueryServiceTest.java @@ -0,0 +1,165 @@ +package notai.llm.application; + +import notai.common.exception.type.InternalServerErrorException; +import notai.common.exception.type.NotFoundException; +import notai.document.domain.DocumentRepository; +import notai.llm.application.result.LLMResultsResult; +import notai.llm.application.result.LLMStatusResult; +import notai.llm.query.LLMQueryRepository; +import notai.problem.query.ProblemQueryRepository; +import notai.problem.query.result.ProblemPageContentResult; +import notai.summary.query.SummaryQueryRepository; +import notai.summary.query.result.SummaryPageContentResult; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static notai.llm.domain.TaskStatus.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class LLMQueryServiceTest { + + @InjectMocks + private LLMQueryService llmQueryService; + + @Mock + private LLMQueryRepository llmQueryRepository; + + @Mock + private DocumentRepository documentRepository; + + @Mock + private SummaryQueryRepository summaryQueryRepository; + + @Mock + private ProblemQueryRepository problemQueryRepository; + + @Test + void 작업_상태_확인시_존재하지_않는_문서ID로_요청한_경우_예외_발생() { + // given + given(documentRepository.existsById(anyLong())).willReturn(false); + + // when & then + assertAll(() -> assertThrows(NotFoundException.class, () -> llmQueryService.fetchTaskStatus(1L)), + () -> verify(documentRepository).existsById(anyLong()) + ); + } + + @Test + void 작업_상태_확인시_모든_페이지의_작업이_완료된_경우_COMPLETED() { + // given + Long documentId = 1L; + List summaryIds = List.of(1L, 2L, 3L); + + given(documentRepository.existsById(anyLong())).willReturn(true); + given(summaryQueryRepository.getSummaryIdsByDocumentId(documentId)).willReturn(summaryIds); + given(llmQueryRepository.getTaskStatusBySummaryId(1L)).willReturn(COMPLETED); + given(llmQueryRepository.getTaskStatusBySummaryId(2L)).willReturn(COMPLETED); + given(llmQueryRepository.getTaskStatusBySummaryId(3L)).willReturn(COMPLETED); + + // when + LLMStatusResult result = llmQueryService.fetchTaskStatus(documentId); + + // then + assertAll(() -> assertThat(result.overallStatus()).isEqualTo(COMPLETED), + () -> assertThat(result.totalPages()).isEqualTo(3), + () -> assertThat(result.completedPages()).isEqualTo(3), + () -> verify(documentRepository).existsById(documentId), + () -> verify(summaryQueryRepository).getSummaryIdsByDocumentId(documentId), + () -> verify(llmQueryRepository).getTaskStatusBySummaryId(documentId) + ); + } + + @Test + void 작업_상태_확인시_모든_페이지의_작업이_완료되지_않은_경우_IN_PROGRESS() { + // given + Long documentId = 1L; + List summaryIds = List.of(1L, 2L, 3L); + + given(documentRepository.existsById(anyLong())).willReturn(true); + given(summaryQueryRepository.getSummaryIdsByDocumentId(documentId)).willReturn(summaryIds); + given(llmQueryRepository.getTaskStatusBySummaryId(1L)).willReturn(COMPLETED); + given(llmQueryRepository.getTaskStatusBySummaryId(2L)).willReturn(IN_PROGRESS); + given(llmQueryRepository.getTaskStatusBySummaryId(3L)).willReturn(PENDING); + + // when + LLMStatusResult result = llmQueryService.fetchTaskStatus(documentId); + + // then + assertAll(() -> assertThat(result.overallStatus()).isEqualTo(IN_PROGRESS), + () -> assertThat(result.totalPages()).isEqualTo(3), + () -> assertThat(result.completedPages()).isEqualTo(1), + () -> verify(documentRepository).existsById(documentId), + () -> verify(summaryQueryRepository).getSummaryIdsByDocumentId(documentId), + () -> verify(llmQueryRepository).getTaskStatusBySummaryId(documentId) + ); + } + + @Test + void 작업_결과_확인시_존재하지_않는_문서ID로_요청한_경우_예외_발생() { + // given + given(documentRepository.existsById(anyLong())).willReturn(false); + + // when & then + assertAll(() -> assertThrows(NotFoundException.class, () -> llmQueryService.findTaskResult(1L)), + () -> verify(documentRepository).existsById(anyLong()) + ); + } + + @Test + void 작업_결과_확인시_생성된_요약과_문제의_수가_일치하지_않는_경우_예외_발생() { + // given + Long documentId = 1L; + List summaryResults = List.of(new SummaryPageContentResult(1, "요약 내용")); + List problemResults = List.of(new ProblemPageContentResult(1, "요약 내용"), + new ProblemPageContentResult(2, "요약 내용") + ); + + given(documentRepository.existsById(anyLong())).willReturn(true); + given(summaryQueryRepository.getPageNumbersAndContentByDocumentId(documentId)).willReturn(summaryResults); + given(problemQueryRepository.getPageNumbersAndContentByDocumentId(documentId)).willReturn(problemResults); + + // when & then + assertAll(() -> assertThrows(InternalServerErrorException.class, () -> llmQueryService.findTaskResult(1L)), + () -> verify(documentRepository).existsById(documentId), + () -> verify(summaryQueryRepository).getPageNumbersAndContentByDocumentId(documentId), + () -> verify(problemQueryRepository).getPageNumbersAndContentByDocumentId(documentId) + ); + } + + @Test + void 작업_결과_확인() { + // given + Long documentId = 1L; + List summaryResults = List.of(new SummaryPageContentResult(1, "요약 내용"), + new SummaryPageContentResult(2, "요약 내용") + ); + List problemResults = List.of(new ProblemPageContentResult(1, "요약 내용"), + new ProblemPageContentResult(2, "요약 내용") + ); + + given(documentRepository.existsById(anyLong())).willReturn(true); + given(summaryQueryRepository.getPageNumbersAndContentByDocumentId(documentId)).willReturn(summaryResults); + given(problemQueryRepository.getPageNumbersAndContentByDocumentId(documentId)).willReturn(problemResults); + + // when + LLMResultsResult response = llmQueryService.findTaskResult(documentId); + + // then + assertAll(() -> assertEquals(documentId, response.documentId()), + () -> assertEquals(2, response.results().size()), + () -> verify(documentRepository).existsById(documentId), + () -> verify(summaryQueryRepository).getPageNumbersAndContentByDocumentId(documentId), + () -> verify(problemQueryRepository).getPageNumbersAndContentByDocumentId(documentId) + ); + } +} \ No newline at end of file diff --git a/src/test/java/notai/llm/application/LLMServiceTest.java b/src/test/java/notai/llm/application/LLMServiceTest.java new file mode 100644 index 0000000..8d85bee --- /dev/null +++ b/src/test/java/notai/llm/application/LLMServiceTest.java @@ -0,0 +1,126 @@ +package notai.llm.application; + +import notai.common.exception.type.NotFoundException; +import notai.document.domain.Document; +import notai.document.domain.DocumentRepository; +import notai.llm.application.command.LLMSubmitCommand; +import notai.llm.application.command.SummaryAndProblemUpdateCommand; +import notai.llm.application.result.LLMSubmitResult; +import notai.llm.domain.LLM; +import notai.llm.domain.LLMRepository; +import notai.problem.domain.Problem; +import notai.problem.domain.ProblemRepository; +import notai.summary.domain.Summary; +import notai.summary.domain.SummaryRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LLMServiceTest { + + @InjectMocks + private LLMService llmService; + + @Mock + private LLMRepository llmRepository; + + @Mock + private DocumentRepository documentRepository; + + @Mock + private SummaryRepository summaryRepository; + + @Mock + private ProblemRepository problemRepository; + + @Test + void AI_기능_요청시_존재하지_않는_문서ID로_요청한_경우_예외_발생() { + // given + Long documentId = 1L; + List pages = List.of(1, 2, 3); + LLMSubmitCommand command = new LLMSubmitCommand(documentId, pages); + + given(documentRepository.findById(anyLong())).willReturn(Optional.empty()); + + // when & then + assertAll(() -> assertThrows(NotFoundException.class, () -> llmService.submitTask(command)), + () -> verify(documentRepository, times(1)).findById(documentId), + () -> verify(llmRepository, never()).save(any(LLM.class)) + ); + } + + @Test + void AI_기능_요청() { + // given + Long documentId = 1L; + List pages = List.of(1, 2, 3); + LLMSubmitCommand command = new LLMSubmitCommand(documentId, pages); + Document document = mock(Document.class); + + given(documentRepository.findById(anyLong())).willReturn(Optional.of(document)); + given(llmRepository.save(any(LLM.class))).willAnswer(invocation -> invocation.getArgument(0)); + // when + LLMSubmitResult result = llmService.submitTask(command); + + // then + assertAll(() -> verify(documentRepository, times(1)).findById(anyLong()), + () -> verify(llmRepository, times(3)).save(any(LLM.class)) + ); + } + + @Test + void AI_서버에서_페이지별_작업이_완료되면_Summary와_Problem_업데이트() { + // given + UUID taskId = UUID.randomUUID(); + Long summaryId = 1L; + Long problemId = 1L; + String summaryContent = "요약 내용"; + String problemContent = "문제 내용"; + Integer pageNumber = 5; + + LLM taskRecord = mock(LLM.class); + Summary summary = mock(Summary.class); + Problem problem = mock(Problem.class); + + SummaryAndProblemUpdateCommand command = new SummaryAndProblemUpdateCommand(taskId, + pageNumber, + summaryContent, + problemContent + ); + + given(llmRepository.getById(any(UUID.class))).willReturn(taskRecord); + given(summaryRepository.getById(anyLong())).willReturn(summary); + given(problemRepository.getById(anyLong())).willReturn(problem); + + given(taskRecord.getSummary()).willReturn(summary); + given(taskRecord.getProblem()).willReturn(problem); + given(summary.getId()).willReturn(summaryId); + given(problem.getId()).willReturn(problemId); + + // when + Integer resultPageNumber = llmService.updateSummaryAndProblem(command); + + // then + assertAll(() -> verify(taskRecord).completeTask(), + () -> verify(summary).updateContent(summaryContent), + () -> verify(problem).updateContent(problemContent), + () -> verify(llmRepository, times(1)).save(taskRecord), + () -> verify(summaryRepository, times(1)).save(summary), + () -> verify(problemRepository, times(1)).save(problem), + () -> assertEquals(pageNumber, resultPageNumber) + ); + } +} \ No newline at end of file From 3f4ca1b1669c8bbf10de538e73707b58c17d1e2a Mon Sep 17 00:00:00 2001 From: Hyun-Seo Jeong <90139789+hynseoj@users.noreply.github.com> Date: Fri, 4 Oct 2024 23:03:03 +0900 Subject: [PATCH 09/12] =?UTF-8?q?Feat:=20Annotaion=20API,=20Recording=20AP?= =?UTF-8?q?I,=20Document=20API=20=EA=B5=AC=ED=98=84=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Style: formatting통일 (#30) * Feat: 요약 및 문제 생성 API 구현 (#32) * Rename: AI Task 도메인 이름 변경 - llm 으로 변경 * Refactor: LLM 도메인 애플리케이션 계층과 프레젠테이션 계층 응답 분리 * Feat: LLM 작업 진행 상태 확인 기능 구현 * Feat: 요약 및 문제 결과 조회 기능 구현 * Feat: 요약 및 문제 생성 기능 구현 - AI 서버와 통신하는 부분 제외하고 기능 구현 - 임시 UUID 를 통해 task 저장 * Feat: LLM 서버 콜백 기능 구현 - LLM 서버가 API 콜을 통해 페이지별 요약 및 문제 내용 전달 - task id 를 통해 조회하여 요약 내용과 문제 내용 업데이트 * Refactor: 변수 이름 변경 - 필드명 카멜케이스로 변경 * Refactor: 통일성 없는 부분 수정 - 필드명 변경 - 변수 추출 * Refactor: 예외 종류, 메서드 네이밍 변경 - LLMQueryService 예외 타입 변경 - SummaryAndProblemUpdateResponse 메서드 네이밍 변경 * Refactor: LLMQueryService 응답과 LLMController 응답 분리 * Style: 코드 포맷팅 통일 (#36) * Feat: 요약 및 문제 생성 API 구현 (#4) * Rename: AI Task 도메인 이름 변경 - llm 으로 변경 * Refactor: LLM 도메인 애플리케이션 계층과 프레젠테이션 계층 응답 분리 * Feat: LLM 작업 진행 상태 확인 기능 구현 * Feat: 요약 및 문제 결과 조회 기능 구현 * Feat: 요약 및 문제 생성 기능 구현 - AI 서버와 통신하는 부분 제외하고 기능 구현 - 임시 UUID 를 통해 task 저장 * Feat: LLM 서버 콜백 기능 구현 - LLM 서버가 API 콜을 통해 페이지별 요약 및 문제 내용 전달 - task id 를 통해 조회하여 요약 내용과 문제 내용 업데이트 * Refactor: 변수 이름 변경 - 필드명 카멜케이스로 변경 * Refactor: 통일성 없는 부분 수정 - 필드명 변경 - 변수 추출 * Refactor: 예외 종류, 메서드 네이밍 변경 - LLMQueryService 예외 타입 변경 - SummaryAndProblemUpdateResponse 메서드 네이밍 변경 * Refactor: LLMQueryService 응답과 LLMController 응답 분리 * Feat: 폴더 관련 기능 구현 (#6) * Init: 프로젝트 기본설정 세팅 - 프로젝트 생성 - .gitignore설정 - 프로젝트 의존성 추가 - application.yml 설정파일 구성 * Init: 프로젝트 기본 구조 및 공통 컴포넌트 설정 - 공통 설정 클래스 추가 (JPA, QueryDSL, Swagger, Web) - 공통 도메인 엔티티 (RootEntity) 정의 - 예외 처리 관련 클래스 및 타입 구현 - JSON 변환을 위한 AttributeConverter 추가 - 유틸리티 클래스 (Math) 추가 * Chore: Folder 도메인 폴더 구조 셋업 폴더 구조 셋업 작업 * Feat: Folder 도메인의 엔티티 생성 엔티티 생성자, 부모-자식간 연결로직 생성 * refactor: 자기 참조 관계 설정 수정 기존, 다대일 양방향 관계에서 다대일 단방향 관계로 설정하고, 삭제 등의 이슈 발생시 Service 계층에서 함수의 재귀사용을 통해 삭제할 예정 * Chore: Document 도메인 폴더 구조 셋업 폴더 구조 셋업 및 엔티티 생성 * Feat: Lombok 라이브러리 활용하여 기본 생성자 생성 기본 생성자 생성 lombok 라이브러리 활용하여 대체 * chore: name 필드의 length 50으로 설정 name 필드 (Document, Folder) 의 length = 50 으로 설정 * chore: Domain 계층의 Repository가 QueryRepository 상속받도록 함 상속 작업 수행 * chore: Member 도메인 매핑 작업 수행 Member 도메인 매핑 작업 수행 * chore: Member 도메인과 Folder 도메인 연결 작업 수행 Member 도메인과 Folder 도메인 연결 작업 수행 * feat: 루트 폴더 생성하는 기능 구현 루트 폴더 생성하는 기능 구현 * feat: 서브폴더 생성하는 기능 구현 서브 폴더 생성하는 기능 구현 * feat: 폴더를 루트로 이동시키는 기능 구현 폴더를 루트로 이동시키는 기능 구현 * feat: 새로운 폴더 내부로 이동시키는 기능 구현 새로운 폴더 내부로 이동시키는 기능 구현 * feat: 계층형 구조의 폴더 탐색 기능 구현 계층형 구조의 폴더 탐색 기능 구현 * test: 재귀적으로 폴더를 조회하는 테스트 코드 작성 재귀적으로 폴더 조회하는 테스트코드 작성 * remove: 사용하지 않는 QueryDSL 관련 파일 삭제 사용하지 않는 QueryDSL 관련 파일 삭제 * refactor: formatting 적용 formatting 적용 * feat: 폴더 재귀적으로 삭제하는 기능 구현 폴더 재귀적으로 삭제하는 기능 구현 * feat: @OnDelete 어노테이션을 사용하여 삭제 기능 구현 삭제 기능 구현 * feat: 폴더 구조의 조회를 간편하게 개선 폴더 구조의 조회 간편하게 개선 * feat: 루트에 폴더를 생성하는 API 구현 루트에 폴더를 생성하는 API 구현 * feat: 서브 폴더를 생성하는 API 구현 서브 폴더 생성하는 API 구현 * feat: 폴더 이동하는 API 구현 폴더 이동하는 API 구현 * refactor: 중복된 함수 기능 병합 작업 수행 중복된 함수 기능 병합 작업 수행 * feat: 폴더 조회 API 구현 폴더 조회 API 구현 * feat: 폴더 삭제 API 구현 폴더 삭제 API 구현 * rename: 함수명 변경 함수 명 변경 * refactor: 메서드 분리 작업 수행 메서드 분리 작업 수행 * refactor: Delete API 204 로 반환 204로 반환 * feat: 요청마다 DTO를 다르게 설정 요청마다 DTO 다르게 설정 * refactor: 타입추론방식에서 타입명시방식으로 변경 타입명시방식으로 코드 스타일 변경 * refactor: 도메인 값에 대한 검증은 도메인계층으로 옮김 도메인 계층으로 값에 대한 검증 이동 * refactor: Owner가 아닌 폴더에 접근하려고 하는 경우 NotFoundException 예외 발생 예외 발생 --------- Co-authored-by: rladbrua0207 <48901587+rladbrua0207@users.noreply.github.com> * Style: 코드 포맷팅 통일 * Refactor: 예외 종류 변경 --------- Co-authored-by: 윤정훈 <76200940+yunjunghun0116@users.noreply.github.com> Co-authored-by: rladbrua0207 <48901587+rladbrua0207@users.noreply.github.com> * Revert "Style: 코드 포맷팅 통일 (#36)" (#38) This reverts commit ad9062e737992caa2bbb3a07dda0072ec086c7ef. * Feat: 폴더 관련 기능 구현 (#39) * Feat: 요약 및 문제 생성 API 구현 (#4) * Rename: AI Task 도메인 이름 변경 - llm 으로 변경 * Refactor: LLM 도메인 애플리케이션 계층과 프레젠테이션 계층 응답 분리 * Feat: LLM 작업 진행 상태 확인 기능 구현 * Feat: 요약 및 문제 결과 조회 기능 구현 * Feat: 요약 및 문제 생성 기능 구현 - AI 서버와 통신하는 부분 제외하고 기능 구현 - 임시 UUID 를 통해 task 저장 * Feat: LLM 서버 콜백 기능 구현 - LLM 서버가 API 콜을 통해 페이지별 요약 및 문제 내용 전달 - task id 를 통해 조회하여 요약 내용과 문제 내용 업데이트 * Refactor: 변수 이름 변경 - 필드명 카멜케이스로 변경 * Refactor: 통일성 없는 부분 수정 - 필드명 변경 - 변수 추출 * Refactor: 예외 종류, 메서드 네이밍 변경 - LLMQueryService 예외 타입 변경 - SummaryAndProblemUpdateResponse 메서드 네이밍 변경 * Refactor: LLMQueryService 응답과 LLMController 응답 분리 * Feat: 폴더 관련 기능 구현 (#6) * Init: 프로젝트 기본설정 세팅 - 프로젝트 생성 - .gitignore설정 - 프로젝트 의존성 추가 - application.yml 설정파일 구성 * Init: 프로젝트 기본 구조 및 공통 컴포넌트 설정 - 공통 설정 클래스 추가 (JPA, QueryDSL, Swagger, Web) - 공통 도메인 엔티티 (RootEntity) 정의 - 예외 처리 관련 클래스 및 타입 구현 - JSON 변환을 위한 AttributeConverter 추가 - 유틸리티 클래스 (Math) 추가 * Chore: Folder 도메인 폴더 구조 셋업 폴더 구조 셋업 작업 * Feat: Folder 도메인의 엔티티 생성 엔티티 생성자, 부모-자식간 연결로직 생성 * refactor: 자기 참조 관계 설정 수정 기존, 다대일 양방향 관계에서 다대일 단방향 관계로 설정하고, 삭제 등의 이슈 발생시 Service 계층에서 함수의 재귀사용을 통해 삭제할 예정 * Chore: Document 도메인 폴더 구조 셋업 폴더 구조 셋업 및 엔티티 생성 * Feat: Lombok 라이브러리 활용하여 기본 생성자 생성 기본 생성자 생성 lombok 라이브러리 활용하여 대체 * chore: name 필드의 length 50으로 설정 name 필드 (Document, Folder) 의 length = 50 으로 설정 * chore: Domain 계층의 Repository가 QueryRepository 상속받도록 함 상속 작업 수행 * chore: Member 도메인 매핑 작업 수행 Member 도메인 매핑 작업 수행 * chore: Member 도메인과 Folder 도메인 연결 작업 수행 Member 도메인과 Folder 도메인 연결 작업 수행 * feat: 루트 폴더 생성하는 기능 구현 루트 폴더 생성하는 기능 구현 * feat: 서브폴더 생성하는 기능 구현 서브 폴더 생성하는 기능 구현 * feat: 폴더를 루트로 이동시키는 기능 구현 폴더를 루트로 이동시키는 기능 구현 * feat: 새로운 폴더 내부로 이동시키는 기능 구현 새로운 폴더 내부로 이동시키는 기능 구현 * feat: 계층형 구조의 폴더 탐색 기능 구현 계층형 구조의 폴더 탐색 기능 구현 * test: 재귀적으로 폴더를 조회하는 테스트 코드 작성 재귀적으로 폴더 조회하는 테스트코드 작성 * remove: 사용하지 않는 QueryDSL 관련 파일 삭제 사용하지 않는 QueryDSL 관련 파일 삭제 * refactor: formatting 적용 formatting 적용 * feat: 폴더 재귀적으로 삭제하는 기능 구현 폴더 재귀적으로 삭제하는 기능 구현 * feat: @OnDelete 어노테이션을 사용하여 삭제 기능 구현 삭제 기능 구현 * feat: 폴더 구조의 조회를 간편하게 개선 폴더 구조의 조회 간편하게 개선 * feat: 루트에 폴더를 생성하는 API 구현 루트에 폴더를 생성하는 API 구현 * feat: 서브 폴더를 생성하는 API 구현 서브 폴더 생성하는 API 구현 * feat: 폴더 이동하는 API 구현 폴더 이동하는 API 구현 * refactor: 중복된 함수 기능 병합 작업 수행 중복된 함수 기능 병합 작업 수행 * feat: 폴더 조회 API 구현 폴더 조회 API 구현 * feat: 폴더 삭제 API 구현 폴더 삭제 API 구현 * rename: 함수명 변경 함수 명 변경 * refactor: 메서드 분리 작업 수행 메서드 분리 작업 수행 * refactor: Delete API 204 로 반환 204로 반환 * feat: 요청마다 DTO를 다르게 설정 요청마다 DTO 다르게 설정 * refactor: 타입추론방식에서 타입명시방식으로 변경 타입명시방식으로 코드 스타일 변경 * refactor: 도메인 값에 대한 검증은 도메인계층으로 옮김 도메인 계층으로 값에 대한 검증 이동 * refactor: Owner가 아닌 폴더에 접근하려고 하는 경우 NotFoundException 예외 발생 예외 발생 --------- Co-authored-by: rladbrua0207 <48901587+rladbrua0207@users.noreply.github.com> --------- Co-authored-by: 윤정훈 <76200940+yunjunghun0116@users.noreply.github.com> Co-authored-by: rladbrua0207 <48901587+rladbrua0207@users.noreply.github.com> * Style: 코드 포맷팅 통일 (#40) * Style: 코드 포맷팅 통일 * Refactor: 예외 종류 변경 --------- Co-authored-by: 윤정훈 <76200940+yunjunghun0116@users.noreply.github.com> Co-authored-by: rladbrua0207 <48901587+rladbrua0207@users.noreply.github.com> * Remove: .idea 폴더 삭제 * Style: 코드 포맷팅 통일 (#41) * Feat: 녹음 파일 업로드 기능 구현 (#8) * Feat: 녹음 파일 업로드 기능 구현 - Recording 엔티티, 레포지토리, 컨트롤러 코드 작성 - 오디오 디코딩, 파일 저장 코드 작성 * Chore: Weekly5 로 rebase * Refactor: file base path @value 를 사용하도록 변경 --------- Co-authored-by: 윤정훈 <76200940+yunjunghun0116@users.noreply.github.com> Co-authored-by: rladbrua0207 <48901587+rladbrua0207@users.noreply.github.com> * Feat: 녹음-페이지 저장 기능 구현 (#10) * Refactor: 메서드, 파라미터 이름 변경 * Feat: 녹음-페이지 저장 기능 구현 - 페이지 넘김 이벤트에 따라 녹음-페이지 테이블에 타임스탬프 저장 * Refactor: 예외 메시지 수정 * Feature/annotation 구현 완료 (#12) * Feat : Annotation CRUD 구현 1. Controller : API 명세서 구현 2. Service : R-CUD를 QueryService, Serivce를 이용하여 구현 3. presentation : DTO 구현 * Refactor: @Positive를 이용한 양수 검증 * Refactor: @NoArgsConstructor의 접근 수준을 PROTECTED로 변경 * Refactor: CreateAnnotationRequest에서 좌표 및 크기 검증 추가 * Refactor: DTO를 record로 통일 * Refactor: getById로 변경 * Refactor: record로 인한 형식 변경 * Refactor: 정적 팩토리 from으로 변경 * Refactor: ManyToOne의 fetch 형식 LAZY로 설정 * Refactor : 정적팩토리 from으로 인한 코드 변경 * Refactor : createAnnotation에서 누락된 savedAnnotatio 추가 * Refactor : pageNumbers 누락 -> 해당 내용을 반영한 Read 구현 * Refactor: CRUD test code 작성 * Refactor : getById로 변경 * Revert "Feature/annotation 구현 완료 (#12)" (#14) This reverts commit 0dcf1d29f5897fd5db772dbf8035d877f84b19e9. * Feat: Annotation API 구현 (#15) * Feat : Annotation CRUD 구현 1. Controller : API 명세서 구현 2. Service : R-CUD를 QueryService, Serivce를 이용하여 구현 3. presentation : DTO 구현 * Refactor: @Positive를 이용한 양수 검증 * Refactor: @NoArgsConstructor의 접근 수준을 PROTECTED로 변경 * Refactor: CreateAnnotationRequest에서 좌표 및 크기 검증 추가 * Refactor: DTO를 record로 통일 * Refactor: getById로 변경 * Refactor: record로 인한 형식 변경 * Refactor: 정적 팩토리 from으로 변경 * Refactor: ManyToOne의 fetch 형식 LAZY로 설정 * Refactor : 정적팩토리 from으로 인한 코드 변경 * Refactor : createAnnotation에서 누락된 savedAnnotatio 추가 * Refactor : pageNumbers 누락 -> 해당 내용을 반영한 Read 구현 * Refactor: CRUD test code 작성 * Refactor : getById로 변경 --------- Co-authored-by: mingjuu Co-authored-by: Minju Song <101880766+mingjuu@users.noreply.github.com> * Feat: 문서 도메인 관련 기능 구현 (#13) * refactor: 폴더의 삭제 방법 재귀형태로 찾아 삭제하도록 개선 개선 * chore: API 매칭 URL 수정 작업 진행 API 매칭 URL 수정 작업 진행 * remove: 불필요한 문서 상태 삭제 불필요한 문서 상태 삭제 * feat: PDF 저장하는 기능 구현 * remove: 불필요한 라이브러리 삭제 불필요한 라이브러리 삭제 * feat: Document 저장하는 기능 구현 * test: PDF Service에서 PDF 저장 로직 테스트코드 작성 PDF 저장 로직 테스트코드 작성 * feat: Document 저장 하는 기능 구현 Document 저장하는 기능 구현 * chore: PDF 처리 및 OCR 라이브러리 라이브러리 import * feat: Document 생성 API 구현 Document 생성 API 구현 * feat: 자료 이름 수정 기능 구현 자료 이름 수정 기능 구현 * feat: 자료 조회 기능 구현 자료 조회 기능 구현 * feat: 자료 삭제 기능 구현 자료 삭제 기능 구현 * feat: 폴더 삭제시 자료도 함께 삭제되도록 기능 구현 삭제 기능 구현 * chore: PDF Setup pdf 세팅 * feat: 루트(메인)에 생성되는 Document 설정 루트 자료 추가 기능 구현 --------- Co-authored-by: rladbrua0207 <48901587+rladbrua0207@users.noreply.github.com> Co-authored-by: mingjuu Co-authored-by: Hyun-Seo Jeong <90139789+hynseoj@users.noreply.github.com> * Fix: 에러 충돌 해결 및 기능 추가 (#16) * Refactor: 코드 취합 후 수정 - document findById 를 getById 로 변경 등 --------- Co-authored-by: yugyeom <48901587+rladbrua0207@users.noreply.github.com> Co-authored-by: 윤정훈 <76200940+yunjunghun0116@users.noreply.github.com> Co-authored-by: Minju Song <101880766+mingjuu@users.noreply.github.com> Co-authored-by: mingjuu --- build.gradle | 6 + .../application/AnnotationQueryService.java | 35 +++++ .../application/AnnotationService.java | 42 ++++++ .../notai/annotation/domain/Annotation.java | 66 +++++++++ .../domain/AnnotationRepository.java | 20 +++ .../presentation/AnnotationController.java | 81 +++++++++++ .../request/CreateAnnotationRequest.java | 29 ++++ .../response/AnnotationResponse.java | 32 +++++ src/main/java/notai/auth/TokenPair.java | 5 +- src/main/java/notai/auth/TokenService.java | 28 ++-- .../client/oauth/OauthClientComposite.java | 9 +- .../oauth/kakao/KakaoMemberResponse.java | 48 ++++--- .../java/notai/comment/domain/Comment.java | 7 +- .../notai/common/config/SwaggerConfig.java | 52 ++++--- .../java/notai/common/domain/vo/FilePath.java | 31 ++++ .../exception/type/FileProcessException.java | 11 ++ .../java/notai/common/utils/AudioDecoder.java | 18 +++ .../java/notai/common/utils/FileManager.java | 20 +++ .../application/DocumentQueryService.java | 16 +++ .../document/application/DocumentService.java | 62 ++++++++ .../document/application/PdfService.java | 47 +++++++ .../result/DocumentFindResult.java | 11 ++ .../result/DocumentSaveResult.java | 11 ++ .../result/DocumentUpdateResult.java | 11 ++ .../java/notai/document/domain/Document.java | 34 +++-- .../document/domain/DocumentRepository.java | 11 ++ .../notai/document/domain/DocumentStatus.java | 5 - .../presentation/DocumentController.java | 66 ++++++++- .../document/presentation/PdfController.java | 30 ++++ .../request/DocumentSaveRequest.java | 6 + .../request/DocumentUpdateRequest.java | 6 + .../response/DocumentFindResponse.java | 13 ++ .../response/DocumentSaveResponse.java | 13 ++ .../response/DocumentUpdateResponse.java | 17 +++ .../application/FolderQueryService.java | 25 ++++ .../folder/application/FolderService.java | 67 +++++++++ .../application/result/FolderFindResult.java | 11 ++ .../application/result/FolderMoveResult.java | 10 ++ .../application/result/FolderSaveResult.java | 11 ++ src/main/java/notai/folder/domain/Folder.java | 9 +- .../notai/folder/domain/FolderRepository.java | 15 +- .../folder/presentation/FolderController.java | 65 ++++++++- .../request/FolderMoveRequest.java | 6 + .../request/FolderSaveRequest.java | 7 + .../response/FolderFindResponse.java | 13 ++ .../response/FolderMoveResponse.java | 12 ++ .../response/FolderSaveResponse.java | 13 ++ .../folder/query/FolderQueryRepository.java | 4 - .../query/FolderQueryRepositoryImpl.java | 10 -- .../llm/application/LLMQueryService.java | 11 +- .../notai/llm/application/LLMService.java | 5 +- .../application/result/LLMStatusResult.java | 4 +- .../java/notai/llm/domain/TaskStatus.java | 4 +- .../notai/llm/presentation/LLMController.java | 2 +- .../request/LLMSubmitRequest.java | 3 +- .../response/LLMResultsResponse.java | 10 +- src/main/java/notai/member/domain/Member.java | 5 +- .../java/notai/member/domain/OauthId.java | 5 +- .../member/presentation/MemberController.java | 6 +- .../response/MemberOauthLoginResopnse.java | 11 -- .../response/MemberOauthLoginResponse.java | 12 ++ .../response/MemberTokenRefreshResponse.java | 3 +- .../application/PageRecordingService.java | 41 ++++++ .../command/PageRecordingSaveCommand.java | 16 +++ .../pageRecording/domain/PageRecording.java | 43 ++++++ .../domain/PageRecordingRepository.java | 7 + .../presentation/PageRecordingController.java | 25 ++++ .../request/PageRecordingSaveRequest.java | 32 +++++ .../query/PageRecordingQueryRepository.java | 12 ++ src/main/java/notai/post/domain/Post.java | 7 +- .../problem/query/ProblemQueryRepository.java | 3 +- .../application/RecordingService.java | 60 ++++++++ .../command/RecordingSaveCommand.java | 7 + .../result/RecordingSaveResult.java | 13 ++ .../notai/recording/domain/Recording.java | 43 ++++++ .../recording/domain/RecordingRepository.java | 10 ++ .../presentation/RecordingController.java | 28 ++++ .../request/RecordingSaveRequest.java | 15 ++ .../response/RecordingSaveResponse.java | 15 ++ .../summary/query/SummaryQueryRepository.java | 5 +- src/main/resources/application-local.yml | 3 + src/main/resources/application.yml | 4 + .../java/notai/BackendApplicationTests.java | 6 +- .../annotation/AnnotationServiceTest.java | 132 ++++++++++++++++++ .../oauth/kakao/KakaoOauthClientTest.java | 11 +- .../application/DocumentServiceTest.java | 13 ++ .../document/application/PdfServiceTest.java | 73 ++++++++++ .../application/FolderQueryServiceTest.java | 64 +++++++++ .../notai/llm/application/LLMServiceTest.java | 9 +- .../application/PageRecordingServiceTest.java | 54 +++++++ .../application/RecordingServiceTest.java | 103 ++++++++++++++ 91 files changed, 1945 insertions(+), 167 deletions(-) create mode 100644 src/main/java/notai/annotation/application/AnnotationQueryService.java create mode 100644 src/main/java/notai/annotation/application/AnnotationService.java create mode 100644 src/main/java/notai/annotation/domain/Annotation.java create mode 100644 src/main/java/notai/annotation/domain/AnnotationRepository.java create mode 100644 src/main/java/notai/annotation/presentation/AnnotationController.java create mode 100644 src/main/java/notai/annotation/presentation/request/CreateAnnotationRequest.java create mode 100644 src/main/java/notai/annotation/presentation/response/AnnotationResponse.java create mode 100644 src/main/java/notai/common/domain/vo/FilePath.java create mode 100644 src/main/java/notai/common/exception/type/FileProcessException.java create mode 100644 src/main/java/notai/common/utils/AudioDecoder.java create mode 100644 src/main/java/notai/common/utils/FileManager.java create mode 100644 src/main/java/notai/document/application/PdfService.java create mode 100644 src/main/java/notai/document/application/result/DocumentFindResult.java create mode 100644 src/main/java/notai/document/application/result/DocumentSaveResult.java create mode 100644 src/main/java/notai/document/application/result/DocumentUpdateResult.java delete mode 100644 src/main/java/notai/document/domain/DocumentStatus.java create mode 100644 src/main/java/notai/document/presentation/PdfController.java create mode 100644 src/main/java/notai/document/presentation/request/DocumentSaveRequest.java create mode 100644 src/main/java/notai/document/presentation/request/DocumentUpdateRequest.java create mode 100644 src/main/java/notai/document/presentation/response/DocumentFindResponse.java create mode 100644 src/main/java/notai/document/presentation/response/DocumentSaveResponse.java create mode 100644 src/main/java/notai/document/presentation/response/DocumentUpdateResponse.java create mode 100644 src/main/java/notai/folder/application/result/FolderFindResult.java create mode 100644 src/main/java/notai/folder/application/result/FolderMoveResult.java create mode 100644 src/main/java/notai/folder/application/result/FolderSaveResult.java create mode 100644 src/main/java/notai/folder/presentation/request/FolderMoveRequest.java create mode 100644 src/main/java/notai/folder/presentation/request/FolderSaveRequest.java create mode 100644 src/main/java/notai/folder/presentation/response/FolderFindResponse.java create mode 100644 src/main/java/notai/folder/presentation/response/FolderMoveResponse.java create mode 100644 src/main/java/notai/folder/presentation/response/FolderSaveResponse.java delete mode 100644 src/main/java/notai/folder/query/FolderQueryRepository.java delete mode 100644 src/main/java/notai/folder/query/FolderQueryRepositoryImpl.java delete mode 100644 src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java create mode 100644 src/main/java/notai/member/presentation/response/MemberOauthLoginResponse.java create mode 100644 src/main/java/notai/pageRecording/application/PageRecordingService.java create mode 100644 src/main/java/notai/pageRecording/application/command/PageRecordingSaveCommand.java create mode 100644 src/main/java/notai/pageRecording/domain/PageRecording.java create mode 100644 src/main/java/notai/pageRecording/domain/PageRecordingRepository.java create mode 100644 src/main/java/notai/pageRecording/presentation/PageRecordingController.java create mode 100644 src/main/java/notai/pageRecording/presentation/request/PageRecordingSaveRequest.java create mode 100644 src/main/java/notai/pageRecording/query/PageRecordingQueryRepository.java create mode 100644 src/main/java/notai/recording/application/RecordingService.java create mode 100644 src/main/java/notai/recording/application/command/RecordingSaveCommand.java create mode 100644 src/main/java/notai/recording/application/result/RecordingSaveResult.java create mode 100644 src/main/java/notai/recording/domain/Recording.java create mode 100644 src/main/java/notai/recording/domain/RecordingRepository.java create mode 100644 src/main/java/notai/recording/presentation/RecordingController.java create mode 100644 src/main/java/notai/recording/presentation/request/RecordingSaveRequest.java create mode 100644 src/main/java/notai/recording/presentation/response/RecordingSaveResponse.java create mode 100644 src/test/java/notai/annotation/AnnotationServiceTest.java create mode 100644 src/test/java/notai/document/application/DocumentServiceTest.java create mode 100644 src/test/java/notai/document/application/PdfServiceTest.java create mode 100644 src/test/java/notai/folder/application/FolderQueryServiceTest.java create mode 100644 src/test/java/notai/pageRecording/application/PageRecordingServiceTest.java create mode 100644 src/test/java/notai/recording/application/RecordingServiceTest.java diff --git a/build.gradle b/build.gradle index 13d01b9..344ed4f 100644 --- a/build.gradle +++ b/build.gradle @@ -53,6 +53,12 @@ dependencies { // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // PDF + implementation 'org.apache.pdfbox:pdfbox:3.0.2' + + // OCR + implementation 'net.sourceforge.tess4j:tess4j:5.13.0' } tasks.named('test') { diff --git a/src/main/java/notai/annotation/application/AnnotationQueryService.java b/src/main/java/notai/annotation/application/AnnotationQueryService.java new file mode 100644 index 0000000..9d3a879 --- /dev/null +++ b/src/main/java/notai/annotation/application/AnnotationQueryService.java @@ -0,0 +1,35 @@ +package notai.annotation.application; + +import lombok.RequiredArgsConstructor; +import notai.annotation.domain.Annotation; +import notai.annotation.domain.AnnotationRepository; +import notai.annotation.presentation.response.AnnotationResponse; +import notai.common.exception.type.NotFoundException; +import notai.document.domain.DocumentRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AnnotationQueryService { + + private final AnnotationRepository annotationRepository; + private final DocumentRepository documentRepository; + + @Transactional(readOnly = true) + public List getAnnotationsByDocumentAndPageNumbers(Long documentId, List pageNumbers) { + documentRepository.getById(documentId); + + List annotations = annotationRepository.findByDocumentIdAndPageNumberIn(documentId, pageNumbers); + if (annotations.isEmpty()) { + throw new NotFoundException("해당 문서에 해당 페이지 번호의 주석이 존재하지 않습니다."); + } + + return annotations.stream() + .map(AnnotationResponse::from) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/notai/annotation/application/AnnotationService.java b/src/main/java/notai/annotation/application/AnnotationService.java new file mode 100644 index 0000000..7aba5be --- /dev/null +++ b/src/main/java/notai/annotation/application/AnnotationService.java @@ -0,0 +1,42 @@ +package notai.annotation.application; + +import lombok.RequiredArgsConstructor; +import notai.annotation.domain.Annotation; +import notai.annotation.domain.AnnotationRepository; +import notai.annotation.presentation.response.AnnotationResponse; +import notai.document.domain.Document; +import notai.document.domain.DocumentRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AnnotationService { + + private final AnnotationRepository annotationRepository; + private final DocumentRepository documentRepository; + + @Transactional + public AnnotationResponse createAnnotation(Long documentId, int pageNumber, int x, int y, int width, int height, String content) { + Document document = documentRepository.getById(documentId); + + Annotation annotation = new Annotation(document, pageNumber, x, y, width, height, content); + Annotation savedAnnotation = annotationRepository.save(annotation); + return AnnotationResponse.from(savedAnnotation); + } + + @Transactional + public AnnotationResponse updateAnnotation(Long documentId, Long annotationId, int x, int y, int width, int height, String content) { + documentRepository.getById(documentId); + Annotation annotation = annotationRepository.getById(annotationId); + annotation.updateAnnotation(x, y, width, height, content); + return AnnotationResponse.from(annotation); + } + + @Transactional + public void deleteAnnotation(Long documentId, Long annotationId) { + documentRepository.getById(documentId); + Annotation annotation = annotationRepository.getById(annotationId); + annotationRepository.delete(annotation); + } +} diff --git a/src/main/java/notai/annotation/domain/Annotation.java b/src/main/java/notai/annotation/domain/Annotation.java new file mode 100644 index 0000000..66db390 --- /dev/null +++ b/src/main/java/notai/annotation/domain/Annotation.java @@ -0,0 +1,66 @@ +package notai.annotation.domain; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.common.domain.RootEntity; +import notai.document.domain.Document; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "annotation") +public class Annotation extends RootEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "document_id") + @NotNull + private Document document; + + @NotNull + private int pageNumber; + + @NotNull + private int x; + + @NotNull + private int y; + + @NotNull + private int width; + + @NotNull + private int height; + + @Column(columnDefinition = "TEXT") + @NotNull + private String content; + + @Override + public Long getId() { + return this.id; + } + + public Annotation(Document document, int pageNumber, int x, int y, int width, int height, String content) { + this.document = document; + this.pageNumber = pageNumber; + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.content = content; + } + + public void updateAnnotation(int x, int y, int width, int height, String content) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.content = content; + } +} diff --git a/src/main/java/notai/annotation/domain/AnnotationRepository.java b/src/main/java/notai/annotation/domain/AnnotationRepository.java new file mode 100644 index 0000000..c05ab3c --- /dev/null +++ b/src/main/java/notai/annotation/domain/AnnotationRepository.java @@ -0,0 +1,20 @@ +package notai.annotation.domain; + +import notai.common.exception.type.NotFoundException; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AnnotationRepository extends JpaRepository { + + List findByDocumentIdAndPageNumberIn(Long documentId, List pageNumbers); + + + Optional findByIdAndDocumentId(Long id, Long documentId); + + default Annotation getById(Long annotationId) { + return findById(annotationId) + .orElseThrow(() -> new NotFoundException("주석을 찾을 수 없습니다. ID: " + annotationId)); + } +} diff --git a/src/main/java/notai/annotation/presentation/AnnotationController.java b/src/main/java/notai/annotation/presentation/AnnotationController.java new file mode 100644 index 0000000..a57414c --- /dev/null +++ b/src/main/java/notai/annotation/presentation/AnnotationController.java @@ -0,0 +1,81 @@ +package notai.annotation.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import notai.annotation.application.AnnotationQueryService; +import notai.annotation.application.AnnotationService; +import notai.annotation.presentation.request.CreateAnnotationRequest; +import notai.annotation.presentation.response.AnnotationResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/documents/{documentId}/annotations") +@RequiredArgsConstructor +public class AnnotationController { + + private final AnnotationService annotationService; + private final AnnotationQueryService annotationQueryService; + + @PostMapping + public ResponseEntity createAnnotation( + @PathVariable Long documentId, @RequestBody @Valid CreateAnnotationRequest request + ) { + + AnnotationResponse response = annotationService.createAnnotation(documentId, + request.pageNumber(), + request.x(), + request.y(), + request.width(), + request.height(), + request.content() + ); + + return new ResponseEntity<>(response, HttpStatus.CREATED); + } + + + @GetMapping + public ResponseEntity> getAnnotations( + @PathVariable Long documentId, @RequestParam List pageNumbers + ) { + + List response = annotationQueryService.getAnnotationsByDocumentAndPageNumbers( + documentId, + pageNumbers + ); + + return new ResponseEntity<>(response, HttpStatus.OK); + } + + @PutMapping("/{annotationId}") + public ResponseEntity updateAnnotation( + @PathVariable Long documentId, + @PathVariable Long annotationId, + @RequestBody @Valid CreateAnnotationRequest request + ) { + + AnnotationResponse response = annotationService.updateAnnotation(documentId, + annotationId, + request.x(), + request.y(), + request.width(), + request.height(), + request.content() + ); + + return new ResponseEntity<>(response, HttpStatus.OK); + } + + @DeleteMapping("/{annotationId}") + public ResponseEntity deleteAnnotation( + @PathVariable Long documentId, @PathVariable Long annotationId + ) { + + annotationService.deleteAnnotation(documentId, annotationId); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } +} diff --git a/src/main/java/notai/annotation/presentation/request/CreateAnnotationRequest.java b/src/main/java/notai/annotation/presentation/request/CreateAnnotationRequest.java new file mode 100644 index 0000000..8e05acc --- /dev/null +++ b/src/main/java/notai/annotation/presentation/request/CreateAnnotationRequest.java @@ -0,0 +1,29 @@ +package notai.annotation.presentation.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; + +public record CreateAnnotationRequest( + + @Positive(message = "페이지 번호는 양수여야 합니다.") + int pageNumber, + +// @Max(value = ?, message = "x 좌표는 최대 ? 이하여야 합니다.") + @PositiveOrZero(message = "x 좌표는 0 이상이어야 합니다.") + int x, + +// @Max(value = ?, message = "x 좌표는 최대 ? 이하여야 합니다.") + @PositiveOrZero(message = "y 좌표는 0 이상이어야 합니다.") + int y, + +// @Max(value = ?, message = "width는 최대 ? 이하여야 합니다.") + @Positive(message = "width는 양수여야 합니다.") + int width, + +// @Max(value = ?, message = "height는 최대 ? 이하여야 합니다.") + @Positive(message = "height는 양수여야 합니다.") + int height, + + String content +) {} diff --git a/src/main/java/notai/annotation/presentation/response/AnnotationResponse.java b/src/main/java/notai/annotation/presentation/response/AnnotationResponse.java new file mode 100644 index 0000000..2e2348c --- /dev/null +++ b/src/main/java/notai/annotation/presentation/response/AnnotationResponse.java @@ -0,0 +1,32 @@ +package notai.annotation.presentation.response; + +import notai.annotation.domain.Annotation; + +public record AnnotationResponse( + Long id, + Long documentId, + int pageNumber, + int x, + int y, + int width, + int height, + String content, + String createdAt, + String updatedAt +) { + + public static AnnotationResponse from(Annotation annotation) { + return new AnnotationResponse( + annotation.getId(), + annotation.getDocument().getId(), + annotation.getPageNumber(), + annotation.getX(), + annotation.getY(), + annotation.getWidth(), + annotation.getHeight(), + annotation.getContent(), + annotation.getCreatedAt().toString(), + annotation.getUpdatedAt().toString() + ); + } +} diff --git a/src/main/java/notai/auth/TokenPair.java b/src/main/java/notai/auth/TokenPair.java index 4e51456..0051082 100644 --- a/src/main/java/notai/auth/TokenPair.java +++ b/src/main/java/notai/auth/TokenPair.java @@ -1,4 +1,7 @@ package notai.auth; -public record TokenPair(String accessToken, String refreshToken) { +public record TokenPair( + String accessToken, + String refreshToken +) { } diff --git a/src/main/java/notai/auth/TokenService.java b/src/main/java/notai/auth/TokenService.java index b4b8510..9204e5a 100644 --- a/src/main/java/notai/auth/TokenService.java +++ b/src/main/java/notai/auth/TokenService.java @@ -29,20 +29,17 @@ public TokenService(TokenProperty tokenProperty, MemberRepository memberReposito } public String createAccessToken(Long memberId) { - return Jwts.builder() - .claim(MEMBER_ID_CLAIM, memberId) - .issuedAt(new Date()) - .expiration(new Date(System.currentTimeMillis() + accessTokenExpirationMillis)) - .signWith(secretKey, Jwts.SIG.HS512) - .compact(); + return Jwts.builder().claim(MEMBER_ID_CLAIM, + memberId + ).issuedAt(new Date()).expiration(new Date(System.currentTimeMillis() + accessTokenExpirationMillis)).signWith(secretKey, + Jwts.SIG.HS512 + ).compact(); } private String createRefreshToken() { - return Jwts.builder() - .issuedAt(new Date()) - .expiration(new Date(System.currentTimeMillis() + refreshTokenExpirationMillis)) - .signWith(secretKey, Jwts.SIG.HS512) - .compact(); + return Jwts.builder().issuedAt(new Date()).expiration(new Date(System.currentTimeMillis() + refreshTokenExpirationMillis)).signWith(secretKey, + Jwts.SIG.HS512 + ).compact(); } public TokenPair createTokenPair(Long memberId) { @@ -71,12 +68,9 @@ public TokenPair refreshTokenPair(String refreshToken) { public Long extractMemberId(String token) { try { - return Jwts.parser() - .verifyWith(secretKey) - .build() - .parseSignedClaims(token) - .getPayload() - .get(MEMBER_ID_CLAIM, Long.class); + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get(MEMBER_ID_CLAIM, + Long.class + ); } catch (Exception e) { throw new UnAuthorizedException("유효하지 않은 토큰입니다."); } diff --git a/src/main/java/notai/client/oauth/OauthClientComposite.java b/src/main/java/notai/client/oauth/OauthClientComposite.java index e4f349b..f65aca6 100644 --- a/src/main/java/notai/client/oauth/OauthClientComposite.java +++ b/src/main/java/notai/client/oauth/OauthClientComposite.java @@ -1,7 +1,5 @@ package notai.client.oauth; -import static java.util.function.Function.identity; -import static java.util.stream.Collectors.toMap; import notai.common.exception.type.BadRequestException; import notai.member.domain.Member; import notai.member.domain.OauthProvider; @@ -11,6 +9,9 @@ import java.util.Optional; import java.util.Set; +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.toMap; + @Component public class OauthClientComposite { @@ -25,7 +26,7 @@ public Member fetchMember(OauthProvider oauthProvider, String accessToken) { } public OauthClient getOauthClient(OauthProvider oauthProvider) { - return Optional.ofNullable(oauthClients.get(oauthProvider)).orElseThrow(() -> new BadRequestException( - "지원하지 않는 소셜 로그인 타입입니다.")); + return Optional.ofNullable(oauthClients.get(oauthProvider)) + .orElseThrow(() -> new BadRequestException("지원하지 않는 소셜 로그인 타입입니다.")); } } diff --git a/src/main/java/notai/client/oauth/kakao/KakaoMemberResponse.java b/src/main/java/notai/client/oauth/kakao/KakaoMemberResponse.java index a6a473e..6e81ed8 100644 --- a/src/main/java/notai/client/oauth/kakao/KakaoMemberResponse.java +++ b/src/main/java/notai/client/oauth/kakao/KakaoMemberResponse.java @@ -10,29 +10,33 @@ @JsonNaming(value = SnakeCaseStrategy.class) public record KakaoMemberResponse( - Long id, - boolean hasSignedUp, - LocalDateTime connectedAt, - KakaoAccount kakaoAccount) { + Long id, + boolean hasSignedUp, + LocalDateTime connectedAt, + KakaoAccount kakaoAccount +) { - public Member toDomain() { - return new Member( - new OauthId(String.valueOf(id), OauthProvider.KAKAO), - kakaoAccount.email, - kakaoAccount.profile.nickname); - } + public Member toDomain() { + return new Member( + new OauthId(String.valueOf(id), OauthProvider.KAKAO), + kakaoAccount.email, + kakaoAccount.profile.nickname + ); + } - @JsonNaming(value = SnakeCaseStrategy.class) - public record KakaoAccount( - Profile profile, - boolean emailNeedsAgreement, - boolean isEmailValid, - boolean isEmailVerified, - String email) { - } + @JsonNaming(value = SnakeCaseStrategy.class) + public record KakaoAccount( + Profile profile, + boolean emailNeedsAgreement, + boolean isEmailValid, + boolean isEmailVerified, + String email + ) { + } - @JsonNaming(value = SnakeCaseStrategy.class) - public record Profile( - String nickname) { - } + @JsonNaming(value = SnakeCaseStrategy.class) + public record Profile( + String nickname + ) { + } } diff --git a/src/main/java/notai/comment/domain/Comment.java b/src/main/java/notai/comment/domain/Comment.java index 269cda6..d3bb860 100644 --- a/src/main/java/notai/comment/domain/Comment.java +++ b/src/main/java/notai/comment/domain/Comment.java @@ -1,10 +1,7 @@ package notai.comment.domain; import jakarta.persistence.*; -import static jakarta.persistence.FetchType.LAZY; -import static jakarta.persistence.GenerationType.IDENTITY; import jakarta.validation.constraints.NotNull; -import static lombok.AccessLevel.PROTECTED; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -12,6 +9,10 @@ import notai.member.domain.Member; import notai.post.domain.Post; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + @Entity @Table(name = "comment") @Getter diff --git a/src/main/java/notai/common/config/SwaggerConfig.java b/src/main/java/notai/common/config/SwaggerConfig.java index 3b79924..f3f77db 100644 --- a/src/main/java/notai/common/config/SwaggerConfig.java +++ b/src/main/java/notai/common/config/SwaggerConfig.java @@ -1,27 +1,43 @@ -package notai.client.oauth.kakao; +package notai.common.config; -import lombok.extern.slf4j.Slf4j; -import notai.common.exception.type.ExternalApiException; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpStatusCode; -import org.springframework.web.client.RestClient; -import static notai.client.HttpInterfaceUtil.createHttpInterface; - -@Slf4j @Configuration -public class KakaoClientConfig { +public class SwaggerConfig { + + private final String serverUrl; + + public SwaggerConfig(@Value("${server-url}") String serverUrl) { + this.serverUrl = serverUrl; + } @Bean - public KakaoClient kakaoClient() { - RestClient restClient = RestClient.builder().defaultStatusHandler(HttpStatusCode::isError, - (request, response) -> { - String responseData = new String(response.getBody().readAllBytes()); - log.error("카카오톡 API 오류 : {}", responseData); - throw new ExternalApiException(responseData, response.getStatusCode().value()); - } - ).build(); - return createHttpInterface(restClient, KakaoClient.class); + public OpenAPI openAPI() { + String jwt = "JWT"; + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwt); + Components components = new Components().addSecuritySchemes(jwt, new SecurityScheme().name(jwt) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .description("토큰값을 입력하여 인증을 활성화할 수 있습니다.") + .bearerFormat("JWT")); + Server server = new Server(); + server.setUrl(serverUrl); + return new OpenAPI().components(new Components()) + .info(apiInfo()) + .addSecurityItem(securityRequirement) + .components(components) + .addServersItem(server); + } + + private Info apiInfo() { + return new Info().title("notai API").description("notai API 문서입니다.").version("0.0.1"); } } diff --git a/src/main/java/notai/common/domain/vo/FilePath.java b/src/main/java/notai/common/domain/vo/FilePath.java new file mode 100644 index 0000000..d9c04af --- /dev/null +++ b/src/main/java/notai/common/domain/vo/FilePath.java @@ -0,0 +1,31 @@ +package notai.common.domain.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.common.exception.type.BadRequestException; + +import static lombok.AccessLevel.PROTECTED; + +@Embeddable +@Getter +@NoArgsConstructor(access = PROTECTED) +public class FilePath { + + @Column(length = 50) + private String filePath; + + private FilePath(String filePath) { + this.filePath = filePath; + } + + public static FilePath from(String filePath) { + // 추후 확장자 추가 + if (!filePath.matches( + "[a-zA-Z0-9ㄱ-ㅎㅏ-ㅣ가-힣()\\[\\]+\\-&/_\\s]+(/[a-zA-Z0-9ㄱ-ㅎㅏ-ㅣ가-힣()\\[\\]+\\-&/_\\s]+)*/?[a-zA-Z0-9ㄱ-ㅎㅏ-ㅣ가-힣()\\[\\]+\\-&/_\\s]+\\.mp3")) { + throw new BadRequestException("지원하지 않는 파일 형식입니다."); + } + return new FilePath(filePath); + } +} diff --git a/src/main/java/notai/common/exception/type/FileProcessException.java b/src/main/java/notai/common/exception/type/FileProcessException.java new file mode 100644 index 0000000..640ff7e --- /dev/null +++ b/src/main/java/notai/common/exception/type/FileProcessException.java @@ -0,0 +1,11 @@ +package notai.common.exception.type; + + +import notai.common.exception.ApplicationException; + +public class FileProcessException extends ApplicationException { + + public FileProcessException(String message) { + super(message, 500); + } +} diff --git a/src/main/java/notai/common/utils/AudioDecoder.java b/src/main/java/notai/common/utils/AudioDecoder.java new file mode 100644 index 0000000..5f78bf5 --- /dev/null +++ b/src/main/java/notai/common/utils/AudioDecoder.java @@ -0,0 +1,18 @@ +package notai.common.utils; + +import org.springframework.stereotype.Component; + +import java.util.Base64; + +@Component +public class AudioDecoder { + + public byte[] decode(String audioData) throws IllegalArgumentException { + String base64AudioData = removeMetaData(audioData); + return Base64.getDecoder().decode(base64AudioData); + } + + private static String removeMetaData(String audioData) { + return audioData.split(",")[1]; + } +} diff --git a/src/main/java/notai/common/utils/FileManager.java b/src/main/java/notai/common/utils/FileManager.java new file mode 100644 index 0000000..1e6b2d7 --- /dev/null +++ b/src/main/java/notai/common/utils/FileManager.java @@ -0,0 +1,20 @@ +package notai.common.utils; + +import org.springframework.stereotype.Component; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +@Component +public class FileManager { + + public void save(byte[] binaryFile, Path path) throws IOException { + Files.createDirectories(path.getParent()); + + try (FileOutputStream fos = new FileOutputStream(path.toFile())) { + fos.write(binaryFile); + } + } +} diff --git a/src/main/java/notai/document/application/DocumentQueryService.java b/src/main/java/notai/document/application/DocumentQueryService.java index c055f04..4f2112f 100644 --- a/src/main/java/notai/document/application/DocumentQueryService.java +++ b/src/main/java/notai/document/application/DocumentQueryService.java @@ -1,9 +1,25 @@ package notai.document.application; import lombok.RequiredArgsConstructor; +import notai.document.application.result.DocumentFindResult; +import notai.document.domain.Document; +import notai.document.domain.DocumentRepository; import org.springframework.stereotype.Service; +import java.util.List; + @Service @RequiredArgsConstructor public class DocumentQueryService { + + private final DocumentRepository documentRepository; + + public List findDocuments(Long folderId) { + List documents = documentRepository.findAllByFolderId(folderId); + return documents.stream().map(this::getDocumentFindResult).toList(); + } + + private DocumentFindResult getDocumentFindResult(Document document) { + return DocumentFindResult.of(document.getId(), document.getName(), document.getUrl()); + } } diff --git a/src/main/java/notai/document/application/DocumentService.java b/src/main/java/notai/document/application/DocumentService.java index b2674f1..1693b4f 100644 --- a/src/main/java/notai/document/application/DocumentService.java +++ b/src/main/java/notai/document/application/DocumentService.java @@ -1,9 +1,71 @@ package notai.document.application; import lombok.RequiredArgsConstructor; +import notai.document.application.result.DocumentSaveResult; +import notai.document.application.result.DocumentUpdateResult; +import notai.document.domain.Document; +import notai.document.domain.DocumentRepository; +import notai.document.presentation.request.DocumentSaveRequest; +import notai.document.presentation.request.DocumentUpdateRequest; +import notai.folder.domain.Folder; +import notai.folder.domain.FolderRepository; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor public class DocumentService { + + private final PdfService pdfService; + private final DocumentRepository documentRepository; + private final FolderRepository folderRepository; + + public DocumentSaveResult saveDocument( + Long folderId, MultipartFile pdfFile, DocumentSaveRequest documentSaveRequest + ) { + String pdfName = pdfService.savePdf(pdfFile); + String pdfUrl = convertPdfUrl(pdfName); + Folder folder = folderRepository.getById(folderId); + Document document = new Document(folder, documentSaveRequest.name(), pdfUrl); + Document savedDocument = documentRepository.save(document); + return DocumentSaveResult.of(savedDocument.getId(), savedDocument.getName(), savedDocument.getUrl()); + } + + public DocumentSaveResult saveRootDocument( + MultipartFile pdfFile, DocumentSaveRequest documentSaveRequest + ) { + String pdfName = pdfService.savePdf(pdfFile); + String pdfUrl = convertPdfUrl(pdfName); + Document document = new Document(documentSaveRequest.name(), pdfUrl); + Document savedDocument = documentRepository.save(document); + return DocumentSaveResult.of(savedDocument.getId(), savedDocument.getName(), savedDocument.getUrl()); + } + + public DocumentUpdateResult updateDocument( + Long folderId, Long documentId, DocumentUpdateRequest documentUpdateRequest + ) { + Document document = documentRepository.getById(documentId); + document.validateDocument(folderId); + document.updateName(documentUpdateRequest.name()); + Document savedDocument = documentRepository.save(document); + return DocumentUpdateResult.of(savedDocument.getId(), savedDocument.getName(), savedDocument.getUrl()); + } + + public void deleteDocument( + Long folderId, Long documentId + ) { + Document document = documentRepository.getById(documentId); + document.validateDocument(folderId); + documentRepository.delete(document); + } + + public void deleteAllByFolder( + Folder folder + ) { + documentRepository.deleteAllByFolder(folder); + } + + private String convertPdfUrl(String pdfName) { + return String.format("pdf/%s", pdfName); + } } diff --git a/src/main/java/notai/document/application/PdfService.java b/src/main/java/notai/document/application/PdfService.java new file mode 100644 index 0000000..74f887a --- /dev/null +++ b/src/main/java/notai/document/application/PdfService.java @@ -0,0 +1,47 @@ +package notai.document.application; + +import lombok.RequiredArgsConstructor; +import notai.common.exception.type.FileProcessException; +import notai.common.exception.type.NotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class PdfService { + + private static final String STORAGE_DIR = "src/main/resources/pdf/"; + + public String savePdf(MultipartFile file) { + try { + Path directoryPath = Paths.get(STORAGE_DIR); + if (!Files.exists(directoryPath)) { + Files.createDirectories(directoryPath); + } + + String fileName = UUID.randomUUID() + ".pdf"; + Path filePath = directoryPath.resolve(fileName); + file.transferTo(filePath.toFile()); + + return fileName; + } catch (IOException exception) { + throw new FileProcessException("자료를 저장하는 과정에서 에러가 발생했습니다."); + } + } + + public File getPdf(String fileName) { + Path filePath = Paths.get(STORAGE_DIR, fileName); + + if (!Files.exists(filePath)) { + throw new NotFoundException("존재하지 않는 파일입니다."); + } + return filePath.toFile(); + } +} diff --git a/src/main/java/notai/document/application/result/DocumentFindResult.java b/src/main/java/notai/document/application/result/DocumentFindResult.java new file mode 100644 index 0000000..634e093 --- /dev/null +++ b/src/main/java/notai/document/application/result/DocumentFindResult.java @@ -0,0 +1,11 @@ +package notai.document.application.result; + +public record DocumentFindResult( + Long id, + String name, + String url +) { + public static DocumentFindResult of(Long id, String name, String url) { + return new DocumentFindResult(id, name, url); + } +} diff --git a/src/main/java/notai/document/application/result/DocumentSaveResult.java b/src/main/java/notai/document/application/result/DocumentSaveResult.java new file mode 100644 index 0000000..9337e0e --- /dev/null +++ b/src/main/java/notai/document/application/result/DocumentSaveResult.java @@ -0,0 +1,11 @@ +package notai.document.application.result; + +public record DocumentSaveResult( + Long id, + String name, + String url +) { + public static DocumentSaveResult of(Long id, String name, String url) { + return new DocumentSaveResult(id, name, url); + } +} diff --git a/src/main/java/notai/document/application/result/DocumentUpdateResult.java b/src/main/java/notai/document/application/result/DocumentUpdateResult.java new file mode 100644 index 0000000..cd1ff31 --- /dev/null +++ b/src/main/java/notai/document/application/result/DocumentUpdateResult.java @@ -0,0 +1,11 @@ +package notai.document.application.result; + +public record DocumentUpdateResult( + Long id, + String name, + String url +) { + public static DocumentUpdateResult of(Long id, String name, String url) { + return new DocumentUpdateResult(id, name, url); + } +} diff --git a/src/main/java/notai/document/domain/Document.java b/src/main/java/notai/document/domain/Document.java index 8135234..f2856f0 100644 --- a/src/main/java/notai/document/domain/Document.java +++ b/src/main/java/notai/document/domain/Document.java @@ -7,6 +7,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import notai.common.domain.RootEntity; +import notai.common.exception.type.NotFoundException; import notai.folder.domain.Folder; @Entity @@ -19,7 +20,6 @@ public class Document extends RootEntity { @GeneratedValue(strategy = IDENTITY) private Long id; - @NotNull @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "folder_id", referencedColumnName = "id") private Folder folder; @@ -29,23 +29,27 @@ public class Document extends RootEntity { private String name; @NotNull - @Column(name = "size") - private Integer size; + @Column(name = "url") + private String url; - @NotNull - @Column(name = "total_page") - private Integer totalPage; + public Document(Folder folder, String name, String url) { + this.folder = folder; + this.name = name; + this.url = url; + } - @NotNull - @Enumerated(value = EnumType.STRING) - @Column(name = "status") - private DocumentStatus status; + public Document(String name, String url) { + this.name = name; + this.url = url; + } - public Document(Folder folder, String name, Integer size, Integer totalPage, DocumentStatus status) { - this.folder = folder; + public void validateDocument(Long folderId) { + if (!this.folder.getId().equals(folderId)) { + throw new NotFoundException("해당 폴더 내에 존재하지 않는 자료입니다."); + } + } + + public void updateName(String name) { this.name = name; - this.size = size; - this.totalPage = totalPage; - this.status = status; } } diff --git a/src/main/java/notai/document/domain/DocumentRepository.java b/src/main/java/notai/document/domain/DocumentRepository.java index 5258454..803dd1b 100644 --- a/src/main/java/notai/document/domain/DocumentRepository.java +++ b/src/main/java/notai/document/domain/DocumentRepository.java @@ -1,7 +1,18 @@ package notai.document.domain; +import notai.common.exception.type.NotFoundException; import notai.document.query.DocumentQueryRepository; +import notai.folder.domain.Folder; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface DocumentRepository extends JpaRepository, DocumentQueryRepository { + default Document getById(Long id) { + return findById(id).orElseThrow(() -> new NotFoundException("자료를 찾을 수 없습니다.")); + } + + List findAllByFolderId(Long folderId); + + void deleteAllByFolder(Folder folder); } diff --git a/src/main/java/notai/document/domain/DocumentStatus.java b/src/main/java/notai/document/domain/DocumentStatus.java deleted file mode 100644 index 3ae523e..0000000 --- a/src/main/java/notai/document/domain/DocumentStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package notai.document.domain; - -public enum DocumentStatus { - EXISTS, GARBAGE -} diff --git a/src/main/java/notai/document/presentation/DocumentController.java b/src/main/java/notai/document/presentation/DocumentController.java index 231a640..2356163 100644 --- a/src/main/java/notai/document/presentation/DocumentController.java +++ b/src/main/java/notai/document/presentation/DocumentController.java @@ -1,11 +1,73 @@ package notai.document.presentation; import lombok.RequiredArgsConstructor; +import notai.document.application.DocumentQueryService; +import notai.document.application.DocumentService; +import notai.document.application.result.DocumentFindResult; +import notai.document.application.result.DocumentSaveResult; +import notai.document.application.result.DocumentUpdateResult; +import notai.document.presentation.request.DocumentSaveRequest; +import notai.document.presentation.request.DocumentUpdateRequest; +import notai.document.presentation.response.DocumentFindResponse; +import notai.document.presentation.response.DocumentSaveResponse; +import notai.document.presentation.response.DocumentUpdateResponse; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URI; +import java.util.List; @Controller -@RequestMapping("/api/documents") +@RequestMapping("/api/folders/{folderId}/documents") @RequiredArgsConstructor public class DocumentController { + + private final DocumentService documentService; + private final DocumentQueryService documentQueryService; + + @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}) + public ResponseEntity saveDocument( + @PathVariable Long folderId, + @RequestPart MultipartFile pdfFile, + @RequestPart DocumentSaveRequest documentSaveRequest + ) { + DocumentSaveResult documentSaveResult; + if (folderId.equals(-1L)) { + documentSaveResult = documentService.saveRootDocument(pdfFile, documentSaveRequest); + } else { + documentSaveResult = documentService.saveDocument(folderId, pdfFile, documentSaveRequest); + } + DocumentSaveResponse response = DocumentSaveResponse.from(documentSaveResult); + String url = String.format("/api/folders/%s/documents/%s", folderId, response.id()); + return ResponseEntity.created(URI.create(url)).body(response); + } + + @PutMapping(value = "/{id}") + public ResponseEntity updateDocument( + @PathVariable Long folderId, @PathVariable Long id, @RequestBody DocumentUpdateRequest documentUpdateRequest + ) { + DocumentUpdateResult documentUpdateResult = documentService.updateDocument(folderId, id, documentUpdateRequest); + DocumentUpdateResponse response = DocumentUpdateResponse.from(documentUpdateResult); + return ResponseEntity.ok(response); + } + + @GetMapping + public ResponseEntity> getDocuments( + @PathVariable Long folderId + ) { + List documentResults = documentQueryService.findDocuments(folderId); + List responses = documentResults.stream().map(DocumentFindResponse::from).toList(); + return ResponseEntity.ok(responses); + } + + @DeleteMapping("/{id}") + public ResponseEntity getDocuments( + @PathVariable Long folderId, @PathVariable Long id + ) { + documentService.deleteDocument(folderId, id); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/notai/document/presentation/PdfController.java b/src/main/java/notai/document/presentation/PdfController.java new file mode 100644 index 0000000..0b70edf --- /dev/null +++ b/src/main/java/notai/document/presentation/PdfController.java @@ -0,0 +1,30 @@ +package notai.document.presentation; + +import lombok.RequiredArgsConstructor; +import notai.document.application.PdfService; +import org.springframework.core.io.FileSystemResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.io.File; + +@Controller +@RequestMapping("/pdf") +@RequiredArgsConstructor +public class PdfController { + + private final PdfService pdfService; + + @GetMapping("/{fileName}") + public ResponseEntity getPdf(@PathVariable String fileName) { + File pdf = pdfService.getPdf(fileName); + FileSystemResource pdfResource = new FileSystemResource(pdf); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=" + fileName).contentType( + MediaType.APPLICATION_PDF).body(pdfResource); + } +} diff --git a/src/main/java/notai/document/presentation/request/DocumentSaveRequest.java b/src/main/java/notai/document/presentation/request/DocumentSaveRequest.java new file mode 100644 index 0000000..0dd2bc0 --- /dev/null +++ b/src/main/java/notai/document/presentation/request/DocumentSaveRequest.java @@ -0,0 +1,6 @@ +package notai.document.presentation.request; + +public record DocumentSaveRequest( + String name +) { +} diff --git a/src/main/java/notai/document/presentation/request/DocumentUpdateRequest.java b/src/main/java/notai/document/presentation/request/DocumentUpdateRequest.java new file mode 100644 index 0000000..c4413fe --- /dev/null +++ b/src/main/java/notai/document/presentation/request/DocumentUpdateRequest.java @@ -0,0 +1,6 @@ +package notai.document.presentation.request; + +public record DocumentUpdateRequest( + String name +) { +} diff --git a/src/main/java/notai/document/presentation/response/DocumentFindResponse.java b/src/main/java/notai/document/presentation/response/DocumentFindResponse.java new file mode 100644 index 0000000..b588780 --- /dev/null +++ b/src/main/java/notai/document/presentation/response/DocumentFindResponse.java @@ -0,0 +1,13 @@ +package notai.document.presentation.response; + +import notai.document.application.result.DocumentFindResult; + +public record DocumentFindResponse( + Long id, + String name, + String url +) { + public static DocumentFindResponse from(DocumentFindResult documentFindResult) { + return new DocumentFindResponse(documentFindResult.id(), documentFindResult.name(), documentFindResult.url()); + } +} diff --git a/src/main/java/notai/document/presentation/response/DocumentSaveResponse.java b/src/main/java/notai/document/presentation/response/DocumentSaveResponse.java new file mode 100644 index 0000000..7158c65 --- /dev/null +++ b/src/main/java/notai/document/presentation/response/DocumentSaveResponse.java @@ -0,0 +1,13 @@ +package notai.document.presentation.response; + +import notai.document.application.result.DocumentSaveResult; + +public record DocumentSaveResponse( + Long id, + String name, + String url +) { + public static DocumentSaveResponse from(DocumentSaveResult documentSaveResult) { + return new DocumentSaveResponse(documentSaveResult.id(), documentSaveResult.name(), documentSaveResult.url()); + } +} diff --git a/src/main/java/notai/document/presentation/response/DocumentUpdateResponse.java b/src/main/java/notai/document/presentation/response/DocumentUpdateResponse.java new file mode 100644 index 0000000..0b16507 --- /dev/null +++ b/src/main/java/notai/document/presentation/response/DocumentUpdateResponse.java @@ -0,0 +1,17 @@ +package notai.document.presentation.response; + +import notai.document.application.result.DocumentUpdateResult; + +public record DocumentUpdateResponse( + Long id, + String name, + String url +) { + public static DocumentUpdateResponse from(DocumentUpdateResult documentUpdateResult) { + return new DocumentUpdateResponse( + documentUpdateResult.id(), + documentUpdateResult.name(), + documentUpdateResult.url() + ); + } +} diff --git a/src/main/java/notai/folder/application/FolderQueryService.java b/src/main/java/notai/folder/application/FolderQueryService.java index b51c863..1a3a830 100644 --- a/src/main/java/notai/folder/application/FolderQueryService.java +++ b/src/main/java/notai/folder/application/FolderQueryService.java @@ -1,9 +1,34 @@ package notai.folder.application; import lombok.RequiredArgsConstructor; +import notai.folder.application.result.FolderFindResult; +import notai.folder.domain.Folder; +import notai.folder.domain.FolderRepository; import org.springframework.stereotype.Service; +import java.util.List; + @Service @RequiredArgsConstructor public class FolderQueryService { + + private final FolderRepository folderRepository; + + public List getFolders(Long memberId, Long parentFolderId) { + List folders = getFoldersWithMemberAndParent(memberId, parentFolderId); + // document read + return folders.stream().map(this::getFolderResult).toList(); + } + + private List getFoldersWithMemberAndParent(Long memberId, Long parentFolderId) { + if (parentFolderId == null) { + return folderRepository.findAllByMemberIdAndParentFolderIsNull(memberId); + } + return folderRepository.findAllByMemberIdAndParentFolderId(memberId, parentFolderId); + } + + private FolderFindResult getFolderResult(Folder folder) { + Long parentFolderId = folder.getParentFolder() != null ? folder.getParentFolder().getId() : null; + return FolderFindResult.of(folder.getId(), parentFolderId, folder.getName()); + } } diff --git a/src/main/java/notai/folder/application/FolderService.java b/src/main/java/notai/folder/application/FolderService.java index 74ca5c6..c6fcf60 100644 --- a/src/main/java/notai/folder/application/FolderService.java +++ b/src/main/java/notai/folder/application/FolderService.java @@ -1,9 +1,76 @@ package notai.folder.application; import lombok.RequiredArgsConstructor; +import notai.document.application.DocumentService; +import notai.folder.application.result.FolderMoveResult; +import notai.folder.application.result.FolderSaveResult; +import notai.folder.domain.Folder; +import notai.folder.domain.FolderRepository; +import notai.folder.presentation.request.FolderMoveRequest; +import notai.folder.presentation.request.FolderSaveRequest; +import notai.member.domain.Member; +import notai.member.domain.MemberRepository; import org.springframework.stereotype.Service; +import java.util.List; + @Service @RequiredArgsConstructor public class FolderService { + + private final FolderRepository folderRepository; + private final MemberRepository memberRepository; + private final DocumentService documentService; + + public FolderSaveResult saveRootFolder(Long memberId, FolderSaveRequest folderSaveRequest) { + Member member = memberRepository.getById(memberId); + Folder folder = new Folder(member, folderSaveRequest.name()); + Folder savedFolder = folderRepository.save(folder); + return getFolderSaveResult(savedFolder); + } + + public FolderSaveResult saveSubFolder(Long memberId, FolderSaveRequest folderSaveRequest) { + Member member = memberRepository.getById(memberId); + Folder parentFolder = folderRepository.getById(folderSaveRequest.parentFolderId()); + Folder folder = new Folder(member, folderSaveRequest.name(), parentFolder); + Folder savedFolder = folderRepository.save(folder); + return getFolderSaveResult(savedFolder); + } + + public FolderMoveResult moveRootFolder(Long memberId, Long id) { + Folder folder = folderRepository.getById(id); + folder.validateOwner(memberId); + folder.moveRootFolder(); + folderRepository.save(folder); + return getFolderMoveResult(folder); + } + + public FolderMoveResult moveNewParentFolder(Long memberId, Long id, FolderMoveRequest folderMoveRequest) { + Folder folder = folderRepository.getById(id); + Folder parentFolder = folderRepository.getById(folderMoveRequest.targetFolderId()); + folder.validateOwner(memberId); + folder.moveNewParentFolder(parentFolder); + folderRepository.save(folder); + return getFolderMoveResult(folder); + } + + public void deleteFolder(Long memberId, Long id) { + Folder folder = folderRepository.getById(id); + folder.validateOwner(memberId); + List subFolders = folderRepository.findAllByParentFolder(folder); + for (Folder subFolder : subFolders) { + deleteFolder(memberId, subFolder.getId()); + } + documentService.deleteAllByFolder(folder); + folderRepository.delete(folder); + } + + private FolderSaveResult getFolderSaveResult(Folder folder) { + Long parentFolderId = folder.getParentFolder() != null ? folder.getParentFolder().getId() : null; + return FolderSaveResult.of(folder.getId(), parentFolderId, folder.getName()); + } + + private FolderMoveResult getFolderMoveResult(Folder folder) { + return FolderMoveResult.of(folder.getId(), folder.getName()); + } } diff --git a/src/main/java/notai/folder/application/result/FolderFindResult.java b/src/main/java/notai/folder/application/result/FolderFindResult.java new file mode 100644 index 0000000..3014ac0 --- /dev/null +++ b/src/main/java/notai/folder/application/result/FolderFindResult.java @@ -0,0 +1,11 @@ +package notai.folder.application.result; + +public record FolderFindResult( + Long id, + Long parentId, + String name +) { + public static FolderFindResult of(Long id, Long parentId, String name) { + return new FolderFindResult(id, parentId, name); + } +} diff --git a/src/main/java/notai/folder/application/result/FolderMoveResult.java b/src/main/java/notai/folder/application/result/FolderMoveResult.java new file mode 100644 index 0000000..4004836 --- /dev/null +++ b/src/main/java/notai/folder/application/result/FolderMoveResult.java @@ -0,0 +1,10 @@ +package notai.folder.application.result; + +public record FolderMoveResult( + Long id, + String name +) { + public static FolderMoveResult of(Long id, String name) { + return new FolderMoveResult(id, name); + } +} diff --git a/src/main/java/notai/folder/application/result/FolderSaveResult.java b/src/main/java/notai/folder/application/result/FolderSaveResult.java new file mode 100644 index 0000000..bb01f50 --- /dev/null +++ b/src/main/java/notai/folder/application/result/FolderSaveResult.java @@ -0,0 +1,11 @@ +package notai.folder.application.result; + +public record FolderSaveResult( + Long id, + Long parentId, + String name +) { + public static FolderSaveResult of(Long id, Long parentId, String name) { + return new FolderSaveResult(id, parentId, name); + } +} diff --git a/src/main/java/notai/folder/domain/Folder.java b/src/main/java/notai/folder/domain/Folder.java index f6367e8..84458bb 100644 --- a/src/main/java/notai/folder/domain/Folder.java +++ b/src/main/java/notai/folder/domain/Folder.java @@ -7,6 +7,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import notai.common.domain.RootEntity; +import notai.common.exception.type.NotFoundException; import notai.member.domain.Member; @Entity @@ -20,7 +21,7 @@ public class Folder extends RootEntity { private Long id; @NotNull - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) private Member member; @@ -50,4 +51,10 @@ public void moveRootFolder() { public void moveNewParentFolder(Folder parentFolder) { this.parentFolder = parentFolder; } + + public void validateOwner(Long memberId) { + if (!this.member.getId().equals(memberId)) { + throw new NotFoundException("해당 이용자가 보유한 폴더 중 이 폴더가 존재하지 않습니다."); + } + } } diff --git a/src/main/java/notai/folder/domain/FolderRepository.java b/src/main/java/notai/folder/domain/FolderRepository.java index 40a2231..d1fbd3c 100644 --- a/src/main/java/notai/folder/domain/FolderRepository.java +++ b/src/main/java/notai/folder/domain/FolderRepository.java @@ -1,7 +1,18 @@ package notai.folder.domain; -import notai.folder.query.FolderQueryRepository; +import notai.common.exception.type.NotFoundException; import org.springframework.data.jpa.repository.JpaRepository; -public interface FolderRepository extends JpaRepository, FolderQueryRepository { +import java.util.List; + +public interface FolderRepository extends JpaRepository { + default Folder getById(Long id) { + return findById(id).orElseThrow(() -> new NotFoundException("폴더 정보를 찾을 수 없습니다.")); + } + + List findAllByMemberIdAndParentFolderIsNull(Long memberId); + + List findAllByMemberIdAndParentFolderId(Long memberId, Long parentFolderId); + + List findAllByParentFolder(Folder parentFolder); } diff --git a/src/main/java/notai/folder/presentation/FolderController.java b/src/main/java/notai/folder/presentation/FolderController.java index 0c0383c..2f503dc 100644 --- a/src/main/java/notai/folder/presentation/FolderController.java +++ b/src/main/java/notai/folder/presentation/FolderController.java @@ -1,10 +1,24 @@ package notai.folder.presentation; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import notai.auth.Auth; import notai.folder.application.FolderQueryService; import notai.folder.application.FolderService; +import notai.folder.application.result.FolderFindResult; +import notai.folder.application.result.FolderMoveResult; +import notai.folder.application.result.FolderSaveResult; +import notai.folder.presentation.request.FolderMoveRequest; +import notai.folder.presentation.request.FolderSaveRequest; +import notai.folder.presentation.response.FolderFindResponse; +import notai.folder.presentation.response.FolderMoveResponse; +import notai.folder.presentation.response.FolderSaveResponse; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; +import java.util.List; @Controller @RequestMapping("/api/folders") @@ -13,4 +27,53 @@ public class FolderController { private final FolderService folderService; private final FolderQueryService folderQueryService; + + @PostMapping + public ResponseEntity saveFolder( + @Auth Long memberId, @Valid @RequestBody FolderSaveRequest folderSaveRequest + ) { + FolderSaveResult folderResult = saveFolderResult(memberId, folderSaveRequest); + FolderSaveResponse response = FolderSaveResponse.from(folderResult); + return ResponseEntity.created(URI.create("/api/folders/" + response.id())).body(response); + } + + @PostMapping("/{id}/move") + public ResponseEntity moveFolder( + @Auth Long memberId, @PathVariable Long id, @Valid @RequestBody FolderMoveRequest folderMoveRequest + ) { + FolderMoveResult folderResult = moveFolderWithRequest(memberId, id, folderMoveRequest); + FolderMoveResponse response = FolderMoveResponse.from(folderResult); + return ResponseEntity.ok(response); + } + + @GetMapping + public ResponseEntity> getFolders( + @Auth Long memberId, @RequestParam(required = false) Long parentFolderId + ) { + List folderResults = folderQueryService.getFolders(memberId, parentFolderId); + List response = folderResults.stream().map(FolderFindResponse::from).toList(); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteFolder( + @Auth Long memberId, @PathVariable Long id + ) { + folderService.deleteFolder(memberId, id); + return ResponseEntity.noContent().build(); + } + + private FolderSaveResult saveFolderResult(Long memberId, FolderSaveRequest folderSaveRequest) { + if (folderSaveRequest.parentFolderId() != null) { + return folderService.saveSubFolder(memberId, folderSaveRequest); + } + return folderService.saveRootFolder(memberId, folderSaveRequest); + } + + private FolderMoveResult moveFolderWithRequest(Long memberId, Long id, FolderMoveRequest folderMoveRequest) { + if (folderMoveRequest.targetFolderId() != null) { + return folderService.moveNewParentFolder(memberId, id, folderMoveRequest); + } + return folderService.moveRootFolder(memberId, id); + } } diff --git a/src/main/java/notai/folder/presentation/request/FolderMoveRequest.java b/src/main/java/notai/folder/presentation/request/FolderMoveRequest.java new file mode 100644 index 0000000..a1f6ff3 --- /dev/null +++ b/src/main/java/notai/folder/presentation/request/FolderMoveRequest.java @@ -0,0 +1,6 @@ +package notai.folder.presentation.request; + +public record FolderMoveRequest( + Long targetFolderId +) { +} diff --git a/src/main/java/notai/folder/presentation/request/FolderSaveRequest.java b/src/main/java/notai/folder/presentation/request/FolderSaveRequest.java new file mode 100644 index 0000000..b16f7f9 --- /dev/null +++ b/src/main/java/notai/folder/presentation/request/FolderSaveRequest.java @@ -0,0 +1,7 @@ +package notai.folder.presentation.request; + +public record FolderSaveRequest( + Long parentFolderId, + String name +) { +} diff --git a/src/main/java/notai/folder/presentation/response/FolderFindResponse.java b/src/main/java/notai/folder/presentation/response/FolderFindResponse.java new file mode 100644 index 0000000..8d0a687 --- /dev/null +++ b/src/main/java/notai/folder/presentation/response/FolderFindResponse.java @@ -0,0 +1,13 @@ +package notai.folder.presentation.response; + +import notai.folder.application.result.FolderFindResult; + +public record FolderFindResponse( + Long id, + Long parentId, + String name +) { + public static FolderFindResponse from(FolderFindResult folderFindResult) { + return new FolderFindResponse(folderFindResult.id(), folderFindResult.parentId(), folderFindResult.name()); + } +} diff --git a/src/main/java/notai/folder/presentation/response/FolderMoveResponse.java b/src/main/java/notai/folder/presentation/response/FolderMoveResponse.java new file mode 100644 index 0000000..0801a69 --- /dev/null +++ b/src/main/java/notai/folder/presentation/response/FolderMoveResponse.java @@ -0,0 +1,12 @@ +package notai.folder.presentation.response; + +import notai.folder.application.result.FolderMoveResult; + +public record FolderMoveResponse( + Long id, + String name +) { + public static FolderMoveResponse from(FolderMoveResult folderMoveResult) { + return new FolderMoveResponse(folderMoveResult.id(), folderMoveResult.name()); + } +} diff --git a/src/main/java/notai/folder/presentation/response/FolderSaveResponse.java b/src/main/java/notai/folder/presentation/response/FolderSaveResponse.java new file mode 100644 index 0000000..cfc552f --- /dev/null +++ b/src/main/java/notai/folder/presentation/response/FolderSaveResponse.java @@ -0,0 +1,13 @@ +package notai.folder.presentation.response; + +import notai.folder.application.result.FolderSaveResult; + +public record FolderSaveResponse( + Long id, + Long parentId, + String name +) { + public static FolderSaveResponse from(FolderSaveResult folderSaveResult) { + return new FolderSaveResponse(folderSaveResult.id(), folderSaveResult.parentId(), folderSaveResult.name()); + } +} diff --git a/src/main/java/notai/folder/query/FolderQueryRepository.java b/src/main/java/notai/folder/query/FolderQueryRepository.java deleted file mode 100644 index 93bdaee..0000000 --- a/src/main/java/notai/folder/query/FolderQueryRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package notai.folder.query; - -public interface FolderQueryRepository { -} diff --git a/src/main/java/notai/folder/query/FolderQueryRepositoryImpl.java b/src/main/java/notai/folder/query/FolderQueryRepositoryImpl.java deleted file mode 100644 index c7b7681..0000000 --- a/src/main/java/notai/folder/query/FolderQueryRepositoryImpl.java +++ /dev/null @@ -1,10 +0,0 @@ -package notai.folder.query; - -import com.querydsl.jpa.impl.JPAQueryFactory; -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -public class FolderQueryRepositoryImpl implements FolderQueryRepository { - - private final JPAQueryFactory jpaQueryFactory; -} diff --git a/src/main/java/notai/llm/application/LLMQueryService.java b/src/main/java/notai/llm/application/LLMQueryService.java index 2858c2a..74924e7 100644 --- a/src/main/java/notai/llm/application/LLMQueryService.java +++ b/src/main/java/notai/llm/application/LLMQueryService.java @@ -1,7 +1,6 @@ package notai.llm.application; import lombok.RequiredArgsConstructor; -import notai.common.exception.type.BadRequestException; import notai.common.exception.type.InternalServerErrorException; import notai.common.exception.type.NotFoundException; import notai.document.domain.DocumentRepository; @@ -80,7 +79,7 @@ private static void checkSummaryAndProblemCountsEqual( private List getSummaryIds(Long documentId) { List summaryIds = summaryQueryRepository.getSummaryIdsByDocumentId(documentId); if (summaryIds.isEmpty()) { - throw new BadRequestException("AI 기능을 요청한 기록이 없습니다."); + throw new NotFoundException("AI 기능을 요청한 기록이 없습니다."); } return summaryIds; } @@ -103,8 +102,10 @@ private List getProblemPageContentResults(Long documen } private String findProblemContentByPageNumber(List results, int pageNumber) { - return results.stream().filter(result -> result.pageNumber() == pageNumber).findFirst().map( - ProblemPageContentResult::content).orElseThrow(() -> new InternalServerErrorException( - "AI 요약 및 문제 생성 중에 문제가 발생했습니다.")); // 요약 페이지와 문제 페이지가 불일치 + return results.stream() + .filter(result -> result.pageNumber() == pageNumber) + .findFirst() + .map(ProblemPageContentResult::content) + .orElseThrow(() -> new InternalServerErrorException("AI 요약 및 문제 생성 중에 문제가 발생했습니다.")); // 요약 페이지와 문제 페이지가 불일치 } } diff --git a/src/main/java/notai/llm/application/LLMService.java b/src/main/java/notai/llm/application/LLMService.java index d6e529e..d8a395f 100644 --- a/src/main/java/notai/llm/application/LLMService.java +++ b/src/main/java/notai/llm/application/LLMService.java @@ -1,7 +1,6 @@ package notai.llm.application; import lombok.RequiredArgsConstructor; -import notai.common.exception.type.NotFoundException; import notai.document.domain.Document; import notai.document.domain.DocumentRepository; import notai.llm.application.command.LLMSubmitCommand; @@ -35,9 +34,7 @@ public class LLMService { private final ProblemRepository problemRepository; public LLMSubmitResult submitTask(LLMSubmitCommand command) { - // TODO: document 개발 코드 올려주시면, getById 로 수정 - Document foundDocument = - documentRepository.findById(command.documentId()).orElseThrow(() -> new NotFoundException("")); + Document foundDocument = documentRepository.getById(command.documentId()); command.pages().forEach(pageNumber -> { UUID taskId = sendRequestToAIServer(); diff --git a/src/main/java/notai/llm/application/result/LLMStatusResult.java b/src/main/java/notai/llm/application/result/LLMStatusResult.java index a9a3768..158e099 100644 --- a/src/main/java/notai/llm/application/result/LLMStatusResult.java +++ b/src/main/java/notai/llm/application/result/LLMStatusResult.java @@ -8,7 +8,9 @@ public record LLMStatusResult( Integer totalPages, Integer completedPages ) { - public static LLMStatusResult of(Long documentId, TaskStatus overallStatus, Integer totalPages, Integer completedPages) { + public static LLMStatusResult of( + Long documentId, TaskStatus overallStatus, Integer totalPages, Integer completedPages + ) { return new LLMStatusResult(documentId, overallStatus, totalPages, completedPages); } } diff --git a/src/main/java/notai/llm/domain/TaskStatus.java b/src/main/java/notai/llm/domain/TaskStatus.java index be44ed8..aa0b6dd 100644 --- a/src/main/java/notai/llm/domain/TaskStatus.java +++ b/src/main/java/notai/llm/domain/TaskStatus.java @@ -1,7 +1,5 @@ package notai.llm.domain; public enum TaskStatus { - PENDING, - IN_PROGRESS, - COMPLETED + PENDING, IN_PROGRESS, COMPLETED } diff --git a/src/main/java/notai/llm/presentation/LLMController.java b/src/main/java/notai/llm/presentation/LLMController.java index 69ad4ff..5d8a6e9 100644 --- a/src/main/java/notai/llm/presentation/LLMController.java +++ b/src/main/java/notai/llm/presentation/LLMController.java @@ -42,7 +42,7 @@ public ResponseEntity fetchTaskStatus(@PathVariable("document @GetMapping("/results/{documentId}") public ResponseEntity findTaskResult(@PathVariable("documentId") Long documentId) { LLMResultsResult result = llmQueryService.findTaskResult(documentId); - return ResponseEntity.ok(LLMResultsResponse.of(result)); + return ResponseEntity.ok(LLMResultsResponse.from(result)); } @PostMapping("/callback") diff --git a/src/main/java/notai/llm/presentation/request/LLMSubmitRequest.java b/src/main/java/notai/llm/presentation/request/LLMSubmitRequest.java index 8f78f1d..1226bb5 100644 --- a/src/main/java/notai/llm/presentation/request/LLMSubmitRequest.java +++ b/src/main/java/notai/llm/presentation/request/LLMSubmitRequest.java @@ -8,8 +8,7 @@ public record LLMSubmitRequest( - @NotNull(message = "문서 ID는 필수 입력 값입니다.") - Long documentId, + @NotNull(message = "문서 ID는 필수 입력 값입니다.") Long documentId, List<@Positive(message = "페이지 번호는 양수여야 합니다.") Integer> pages ) { diff --git a/src/main/java/notai/llm/presentation/response/LLMResultsResponse.java b/src/main/java/notai/llm/presentation/response/LLMResultsResponse.java index 535a1f2..4b7a688 100644 --- a/src/main/java/notai/llm/presentation/response/LLMResultsResponse.java +++ b/src/main/java/notai/llm/presentation/response/LLMResultsResponse.java @@ -11,11 +11,11 @@ public record LLMResultsResponse( Integer totalPages, List results ) { - public static LLMResultsResponse of(LLMResultsResult result) { + public static LLMResultsResponse from(LLMResultsResult result) { return new LLMResultsResponse( result.documentId(), result.results().size(), - result.results().stream().map(Result::of).toList() + result.results().stream().map(Result::from).toList() ); } @@ -23,8 +23,8 @@ public record Result( Integer pageNumber, Content content ) { - public static Result of(LLMResult result) { - return new Result(result.pageNumber(), Content.of(result.content())); + public static Result from(LLMResult result) { + return new Result(result.pageNumber(), Content.from(result.content())); } } @@ -32,7 +32,7 @@ public record Content( String summary, String problem ) { - public static Content of(LLMContent result) { + public static Content from(LLMContent result) { return new Content(result.summary(), result.problem()); } } diff --git a/src/main/java/notai/member/domain/Member.java b/src/main/java/notai/member/domain/Member.java index 2cbb807..90cb75a 100644 --- a/src/main/java/notai/member/domain/Member.java +++ b/src/main/java/notai/member/domain/Member.java @@ -1,14 +1,15 @@ package notai.member.domain; import jakarta.persistence.*; -import static jakarta.persistence.GenerationType.IDENTITY; import jakarta.validation.constraints.NotNull; -import static lombok.AccessLevel.PROTECTED; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import notai.common.domain.RootEntity; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + @Entity @Table(name = "member") @Getter diff --git a/src/main/java/notai/member/domain/OauthId.java b/src/main/java/notai/member/domain/OauthId.java index b1765fd..cb2bf87 100644 --- a/src/main/java/notai/member/domain/OauthId.java +++ b/src/main/java/notai/member/domain/OauthId.java @@ -2,14 +2,15 @@ import jakarta.persistence.Column; import jakarta.persistence.Embeddable; -import static jakarta.persistence.EnumType.STRING; import jakarta.persistence.Enumerated; import jakarta.validation.constraints.NotNull; -import static lombok.AccessLevel.PROTECTED; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import static jakarta.persistence.EnumType.STRING; +import static lombok.AccessLevel.PROTECTED; + @Getter @Embeddable @AllArgsConstructor diff --git a/src/main/java/notai/member/presentation/MemberController.java b/src/main/java/notai/member/presentation/MemberController.java index 2843b03..770b655 100644 --- a/src/main/java/notai/member/presentation/MemberController.java +++ b/src/main/java/notai/member/presentation/MemberController.java @@ -12,7 +12,7 @@ import notai.member.presentation.request.OauthLoginRequest; import notai.member.presentation.request.TokenRefreshRequest; import notai.member.presentation.response.MemberFindResponse; -import notai.member.presentation.response.MemberOauthLoginResopnse; +import notai.member.presentation.response.MemberOauthLoginResponse; import notai.member.presentation.response.MemberTokenRefreshResponse; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -28,13 +28,13 @@ public class MemberController { private final TokenService tokenService; @PostMapping("/oauth/login/{oauthProvider}") - public ResponseEntity loginWithOauth( + public ResponseEntity loginWithOauth( @PathVariable(value = "oauthProvider") OauthProvider oauthProvider, @RequestBody OauthLoginRequest request ) { Member member = oauthClient.fetchMember(oauthProvider, request.oauthAccessToken()); Long memberId = memberService.login(member); TokenPair tokenPair = tokenService.createTokenPair(memberId); - return ResponseEntity.ok(MemberOauthLoginResopnse.from(tokenPair)); + return ResponseEntity.ok(MemberOauthLoginResponse.from(tokenPair)); } @PostMapping("/token/refresh") diff --git a/src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java b/src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java deleted file mode 100644 index 7655d6d..0000000 --- a/src/main/java/notai/member/presentation/response/MemberOauthLoginResopnse.java +++ /dev/null @@ -1,11 +0,0 @@ -package notai.member.presentation.response; - -import notai.auth.TokenPair; - -public record MemberOauthLoginResopnse( - String accessToken, String refreshToken -) { - public static MemberOauthLoginResopnse from(TokenPair tokenPair) { - return new MemberOauthLoginResopnse(tokenPair.accessToken(), tokenPair.refreshToken()); - } -} diff --git a/src/main/java/notai/member/presentation/response/MemberOauthLoginResponse.java b/src/main/java/notai/member/presentation/response/MemberOauthLoginResponse.java new file mode 100644 index 0000000..0713d11 --- /dev/null +++ b/src/main/java/notai/member/presentation/response/MemberOauthLoginResponse.java @@ -0,0 +1,12 @@ +package notai.member.presentation.response; + +import notai.auth.TokenPair; + +public record MemberOauthLoginResponse( + String accessToken, + String refreshToken +) { + public static MemberOauthLoginResponse from(TokenPair tokenPair) { + return new MemberOauthLoginResponse(tokenPair.accessToken(), tokenPair.refreshToken()); + } +} diff --git a/src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java b/src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java index b10b135..6273edd 100644 --- a/src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java +++ b/src/main/java/notai/member/presentation/response/MemberTokenRefreshResponse.java @@ -3,7 +3,8 @@ import notai.auth.TokenPair; public record MemberTokenRefreshResponse( - String accessToken, String refreshToken + String accessToken, + String refreshToken ) { public static MemberTokenRefreshResponse from(TokenPair tokenPair) { return new MemberTokenRefreshResponse(tokenPair.accessToken(), tokenPair.refreshToken()); diff --git a/src/main/java/notai/pageRecording/application/PageRecordingService.java b/src/main/java/notai/pageRecording/application/PageRecordingService.java new file mode 100644 index 0000000..4baae7a --- /dev/null +++ b/src/main/java/notai/pageRecording/application/PageRecordingService.java @@ -0,0 +1,41 @@ +package notai.pageRecording.application; + +import lombok.RequiredArgsConstructor; +import notai.common.exception.type.NotFoundException; +import notai.pageRecording.application.command.PageRecordingSaveCommand; +import notai.pageRecording.domain.PageRecording; +import notai.pageRecording.domain.PageRecordingRepository; +import notai.recording.domain.Recording; +import notai.recording.domain.RecordingRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class PageRecordingService { + + private final PageRecordingRepository pageRecordingRepository; + private final RecordingRepository recordingRepository; + + public void savePageRecording(PageRecordingSaveCommand command) { + Recording foundRecording = recordingRepository.getById(command.recordingId()); + checkDocumentOwnershipOfRecording(command, foundRecording); + + command.sessions().forEach(session -> { + PageRecording pageRecording = new PageRecording( + foundRecording, + session.pageNumber(), + session.startTime(), + session.endTime() + ); + pageRecordingRepository.save(pageRecording); + }); + } + + private static void checkDocumentOwnershipOfRecording(PageRecordingSaveCommand command, Recording foundRecording) { + if (!foundRecording.isRecordingOwnedByDocument(command.documentId())) { + throw new NotFoundException("해당 녹음 파일을 찾을 수 없습니다."); + } + } +} diff --git a/src/main/java/notai/pageRecording/application/command/PageRecordingSaveCommand.java b/src/main/java/notai/pageRecording/application/command/PageRecordingSaveCommand.java new file mode 100644 index 0000000..e8397e4 --- /dev/null +++ b/src/main/java/notai/pageRecording/application/command/PageRecordingSaveCommand.java @@ -0,0 +1,16 @@ +package notai.pageRecording.application.command; + +import java.util.List; + +public record PageRecordingSaveCommand( + Long documentId, + Long recordingId, + List sessions +) { + public record PageRecordingSession( + Integer pageNumber, + Double startTime, + Double endTime + ) { + } +} diff --git a/src/main/java/notai/pageRecording/domain/PageRecording.java b/src/main/java/notai/pageRecording/domain/PageRecording.java new file mode 100644 index 0000000..288ae1f --- /dev/null +++ b/src/main/java/notai/pageRecording/domain/PageRecording.java @@ -0,0 +1,43 @@ +package notai.pageRecording.domain; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.recording.domain.Recording; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +@Table(name = "page_recording") +public class PageRecording { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @NotNull + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "recording_id") + private Recording recording; + + @NotNull + private Integer pageNumber; + + @NotNull + private Double startTime; + + @NotNull + private Double endTime; + + public PageRecording(Recording recording, Integer pageNumber, Double startTime, Double endTime) { + this.recording = recording; + this.pageNumber = pageNumber; + this.startTime = startTime; + this.endTime = endTime; + } +} diff --git a/src/main/java/notai/pageRecording/domain/PageRecordingRepository.java b/src/main/java/notai/pageRecording/domain/PageRecordingRepository.java new file mode 100644 index 0000000..01e84fe --- /dev/null +++ b/src/main/java/notai/pageRecording/domain/PageRecordingRepository.java @@ -0,0 +1,7 @@ +package notai.pageRecording.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PageRecordingRepository extends JpaRepository { + +} diff --git a/src/main/java/notai/pageRecording/presentation/PageRecordingController.java b/src/main/java/notai/pageRecording/presentation/PageRecordingController.java new file mode 100644 index 0000000..a5cfc4c --- /dev/null +++ b/src/main/java/notai/pageRecording/presentation/PageRecordingController.java @@ -0,0 +1,25 @@ +package notai.pageRecording.presentation; + +import lombok.RequiredArgsConstructor; +import notai.pageRecording.application.PageRecordingService; +import notai.pageRecording.application.command.PageRecordingSaveCommand; +import notai.pageRecording.presentation.request.PageRecordingSaveRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/documents/{documentId}/recordings/page-turns") +@RequiredArgsConstructor +public class PageRecordingController { + + private final PageRecordingService pageRecordingService; + + @PostMapping + public ResponseEntity savePageRecording( + @PathVariable("documentId") Long documentId, @RequestBody PageRecordingSaveRequest request + ) { + PageRecordingSaveCommand command = request.toCommand(documentId); + pageRecordingService.savePageRecording(command); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/notai/pageRecording/presentation/request/PageRecordingSaveRequest.java b/src/main/java/notai/pageRecording/presentation/request/PageRecordingSaveRequest.java new file mode 100644 index 0000000..1cb18c5 --- /dev/null +++ b/src/main/java/notai/pageRecording/presentation/request/PageRecordingSaveRequest.java @@ -0,0 +1,32 @@ +package notai.pageRecording.presentation.request; + +import notai.pageRecording.application.command.PageRecordingSaveCommand; + +import java.util.List; +import java.util.stream.IntStream; + +public record PageRecordingSaveRequest( + Long recordingId, + List events +) { + public PageRecordingSaveCommand toCommand(Long documentId) { + return new PageRecordingSaveCommand( + documentId, + recordingId, + IntStream.range(0, events.size()) + .mapToObj(i -> new PageRecordingSaveCommand.PageRecordingSession( + events.get(i).nextPage(), + events.get(i).timestamp(), // 페이지 넘김 이벤트가 발생했을 때의 시간을 페이지별 녹음의 시작 시간으로 둡니다. + (i < events.size() - 1) ? events.get(i + 1).timestamp() : null // 다음 페이지 넘김 이벤트가 발생했을 때의 시간을 끝 시간으로 둡니다. 마지막 페이지의 끝 시간은 null 입니다. + )) + .toList() + ); + } + + public record PageTurnEvent( + Integer prevPage, + Integer nextPage, + Double timestamp + ) { + } +} diff --git a/src/main/java/notai/pageRecording/query/PageRecordingQueryRepository.java b/src/main/java/notai/pageRecording/query/PageRecordingQueryRepository.java new file mode 100644 index 0000000..fa6e7c1 --- /dev/null +++ b/src/main/java/notai/pageRecording/query/PageRecordingQueryRepository.java @@ -0,0 +1,12 @@ +package notai.pageRecording.query; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class PageRecordingQueryRepository { + + private final JPAQueryFactory queryFactory; +} diff --git a/src/main/java/notai/post/domain/Post.java b/src/main/java/notai/post/domain/Post.java index 9202ab6..44e18b1 100644 --- a/src/main/java/notai/post/domain/Post.java +++ b/src/main/java/notai/post/domain/Post.java @@ -1,15 +1,16 @@ package notai.post.domain; import jakarta.persistence.*; -import static jakarta.persistence.FetchType.LAZY; -import static jakarta.persistence.GenerationType.IDENTITY; import jakarta.validation.constraints.NotNull; -import static lombok.AccessLevel.PROTECTED; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import notai.member.domain.Member; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + @Getter @NoArgsConstructor(access = PROTECTED) @AllArgsConstructor diff --git a/src/main/java/notai/problem/query/ProblemQueryRepository.java b/src/main/java/notai/problem/query/ProblemQueryRepository.java index 615c321..e1a51f0 100644 --- a/src/main/java/notai/problem/query/ProblemQueryRepository.java +++ b/src/main/java/notai/problem/query/ProblemQueryRepository.java @@ -35,8 +35,7 @@ public List getPageNumbersAndContentByDocumentId(Long problem.content )) .from(problem) - .where(problem.document.id.eq(documentId) - .and(problem.content.isNotNull())) + .where(problem.document.id.eq(documentId).and(problem.content.isNotNull())) .fetch(); } } diff --git a/src/main/java/notai/recording/application/RecordingService.java b/src/main/java/notai/recording/application/RecordingService.java new file mode 100644 index 0000000..3098466 --- /dev/null +++ b/src/main/java/notai/recording/application/RecordingService.java @@ -0,0 +1,60 @@ +package notai.recording.application; + +import lombok.RequiredArgsConstructor; +import notai.common.domain.vo.FilePath; +import notai.common.exception.type.BadRequestException; +import notai.common.exception.type.InternalServerErrorException; +import notai.common.utils.AudioDecoder; +import notai.common.utils.FileManager; +import notai.document.domain.Document; +import notai.document.domain.DocumentRepository; +import notai.recording.application.command.RecordingSaveCommand; +import notai.recording.application.result.RecordingSaveResult; +import notai.recording.domain.Recording; +import notai.recording.domain.RecordingRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; + +@Service +@Transactional +@RequiredArgsConstructor +public class RecordingService { + + private final RecordingRepository recordingRepository; + private final DocumentRepository documentRepository; + private final AudioDecoder audioDecoder; + private final FileManager fileManager; + + @Value("${file.audio.basePath}") + private String audioBasePath; + + public RecordingSaveResult saveRecording(RecordingSaveCommand command) { + Document foundDocument = documentRepository.getById(command.documentId()); + + Recording recording = new Recording(foundDocument); + Recording savedRecording = recordingRepository.save(recording); + + FilePath filePath = + FilePath.from(audioBasePath + foundDocument.getName() + "_" + savedRecording.getId() + ".mp3"); + + try { + byte[] binaryAudioData = audioDecoder.decode(command.audioData()); + Path outputPath = Paths.get(filePath.getFilePath()); + + fileManager.save(binaryAudioData, outputPath); + savedRecording.updateFilePath(filePath); + + return RecordingSaveResult.of(savedRecording.getId(), foundDocument.getId(), savedRecording.getCreatedAt()); + + } catch (IllegalArgumentException e) { + throw new BadRequestException("오디오 파일이 잘못되었습니다."); + } catch (IOException e) { + throw new InternalServerErrorException("녹음 파일 저장 중 오류가 발생했습니다."); // TODO: 재시도 로직 추가? + } + } +} diff --git a/src/main/java/notai/recording/application/command/RecordingSaveCommand.java b/src/main/java/notai/recording/application/command/RecordingSaveCommand.java new file mode 100644 index 0000000..e0270e4 --- /dev/null +++ b/src/main/java/notai/recording/application/command/RecordingSaveCommand.java @@ -0,0 +1,7 @@ +package notai.recording.application.command; + +public record RecordingSaveCommand( + Long documentId, + String audioData +) { +} diff --git a/src/main/java/notai/recording/application/result/RecordingSaveResult.java b/src/main/java/notai/recording/application/result/RecordingSaveResult.java new file mode 100644 index 0000000..29c2ece --- /dev/null +++ b/src/main/java/notai/recording/application/result/RecordingSaveResult.java @@ -0,0 +1,13 @@ +package notai.recording.application.result; + +import java.time.LocalDateTime; + +public record RecordingSaveResult( + Long recordingId, + Long documentId, + LocalDateTime createdAt +) { + public static RecordingSaveResult of(Long recordingId, Long documentId, LocalDateTime createdAt) { + return new RecordingSaveResult(recordingId, documentId, createdAt); + } +} diff --git a/src/main/java/notai/recording/domain/Recording.java b/src/main/java/notai/recording/domain/Recording.java new file mode 100644 index 0000000..64a8c3b --- /dev/null +++ b/src/main/java/notai/recording/domain/Recording.java @@ -0,0 +1,43 @@ +package notai.recording.domain; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.common.domain.RootEntity; +import notai.common.domain.vo.FilePath; +import notai.document.domain.Document; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +public class Recording extends RootEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @NotNull + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "document_id") + private Document document; + + @Embedded + private FilePath filePath; + + public Recording(Document document) { + this.document = document; + } + + public void updateFilePath(FilePath filePath) { + this.filePath = filePath; + } + + public boolean isRecordingOwnedByDocument(Long documentId) { + return this.document.getId().equals(documentId); + } +} diff --git a/src/main/java/notai/recording/domain/RecordingRepository.java b/src/main/java/notai/recording/domain/RecordingRepository.java new file mode 100644 index 0000000..1c667aa --- /dev/null +++ b/src/main/java/notai/recording/domain/RecordingRepository.java @@ -0,0 +1,10 @@ +package notai.recording.domain; + +import notai.common.exception.type.NotFoundException; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RecordingRepository extends JpaRepository { + default Recording getById(Long id) { + return findById(id).orElseThrow(() -> new NotFoundException("해당 녹음 정보를 찾을 수 없습니다.")); + } +} diff --git a/src/main/java/notai/recording/presentation/RecordingController.java b/src/main/java/notai/recording/presentation/RecordingController.java new file mode 100644 index 0000000..92e5de5 --- /dev/null +++ b/src/main/java/notai/recording/presentation/RecordingController.java @@ -0,0 +1,28 @@ +package notai.recording.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import notai.recording.application.RecordingService; +import notai.recording.application.command.RecordingSaveCommand; +import notai.recording.application.result.RecordingSaveResult; +import notai.recording.presentation.request.RecordingSaveRequest; +import notai.recording.presentation.response.RecordingSaveResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/documents/{documentId}/recordings") +@RequiredArgsConstructor +public class RecordingController { + + private final RecordingService recordingService; + + @PostMapping + public ResponseEntity saveRecording( + @PathVariable("documentId") Long documentId, @RequestBody @Valid RecordingSaveRequest request + ) { + RecordingSaveCommand command = request.toCommand(documentId); + RecordingSaveResult result = recordingService.saveRecording(command); + return ResponseEntity.ok(RecordingSaveResponse.from(result)); + } +} diff --git a/src/main/java/notai/recording/presentation/request/RecordingSaveRequest.java b/src/main/java/notai/recording/presentation/request/RecordingSaveRequest.java new file mode 100644 index 0000000..3322eba --- /dev/null +++ b/src/main/java/notai/recording/presentation/request/RecordingSaveRequest.java @@ -0,0 +1,15 @@ +package notai.recording.presentation.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import notai.recording.application.command.RecordingSaveCommand; + +public record RecordingSaveRequest( + @NotBlank String documentName, + + @Pattern(regexp = "data:audio/mpeg;base64,[a-zA-Z0-9+/=]+", message = "지원하지 않는 오디오 형식입니다.") String audioData +) { + public RecordingSaveCommand toCommand(Long documentId) { + return new RecordingSaveCommand(documentId, audioData); + } +} diff --git a/src/main/java/notai/recording/presentation/response/RecordingSaveResponse.java b/src/main/java/notai/recording/presentation/response/RecordingSaveResponse.java new file mode 100644 index 0000000..07645a8 --- /dev/null +++ b/src/main/java/notai/recording/presentation/response/RecordingSaveResponse.java @@ -0,0 +1,15 @@ +package notai.recording.presentation.response; + +import notai.recording.application.result.RecordingSaveResult; + +import java.time.LocalDateTime; + +public record RecordingSaveResponse( + Long recordingId, + Long documentId, + LocalDateTime createdAt +) { + public static RecordingSaveResponse from(RecordingSaveResult result) { + return new RecordingSaveResponse(result.recordingId(), result.documentId(), result.createdAt()); + } +} diff --git a/src/main/java/notai/summary/query/SummaryQueryRepository.java b/src/main/java/notai/summary/query/SummaryQueryRepository.java index b767161..e522293 100644 --- a/src/main/java/notai/summary/query/SummaryQueryRepository.java +++ b/src/main/java/notai/summary/query/SummaryQueryRepository.java @@ -3,8 +3,8 @@ import com.querydsl.core.types.Projections; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; -import notai.summary.query.result.SummaryPageContentResult; import notai.summary.domain.QSummary; +import notai.summary.query.result.SummaryPageContentResult; import org.springframework.stereotype.Repository; import java.util.List; @@ -35,8 +35,7 @@ public List getPageNumbersAndContentByDocumentId(Long summary.content )) .from(summary) - .where(summary.document.id.eq(documentId) - .and(summary.content.isNotNull())) + .where(summary.document.id.eq(documentId).and(summary.content.isNotNull())) .fetch(); } } diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 9f6d882..ba990f8 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -26,6 +26,9 @@ spring: hibernate: ddl-auto: create defer-datasource-initialization: true + mvc: + converters: + preferred-json-mapper: gson server: servlet: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d74c444..79a97f3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,7 @@ spring: profiles: active: local + +file: + audio: + basePath: src/main/resources/audio/ \ No newline at end of file diff --git a/src/test/java/notai/BackendApplicationTests.java b/src/test/java/notai/BackendApplicationTests.java index b50683a..e34c1e0 100644 --- a/src/test/java/notai/BackendApplicationTests.java +++ b/src/test/java/notai/BackendApplicationTests.java @@ -6,8 +6,8 @@ @SpringBootTest class BackendApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } } diff --git a/src/test/java/notai/annotation/AnnotationServiceTest.java b/src/test/java/notai/annotation/AnnotationServiceTest.java new file mode 100644 index 0000000..36d21f0 --- /dev/null +++ b/src/test/java/notai/annotation/AnnotationServiceTest.java @@ -0,0 +1,132 @@ +package notai.annotation; + +import notai.annotation.application.AnnotationQueryService; +import notai.annotation.application.AnnotationService; +import notai.annotation.presentation.AnnotationController; +import notai.annotation.presentation.request.CreateAnnotationRequest; +import notai.annotation.presentation.response.AnnotationResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +class AnnotationControllerTest { + + @Mock + private AnnotationService annotationService; + + @Mock + private AnnotationQueryService annotationQueryService; + + @InjectMocks + private AnnotationController annotationController; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + MockitoAnnotations.initMocks(this); + mockMvc = MockMvcBuilders.standaloneSetup(annotationController).build(); + } + + @Test + void testCreateAnnotation_success() throws Exception { + CreateAnnotationRequest request = new CreateAnnotationRequest(1, 100, 200, 300, 100, "굵은글씨"); + LocalDateTime now = LocalDateTime.now(); + AnnotationResponse response = new AnnotationResponse(1L, 1L, 1, 100, 200, 300, 100, "굵은글씨", now.toString(), now.toString()); + + when(annotationService.createAnnotation(anyLong(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyString())) + .thenReturn(response); + + mockMvc.perform(post("/api/documents/1/annotations") + .contentType("application/json") + .content("{\"pageNumber\": 1, \"x\": 100, \"y\": 200, \"width\": 300, \"height\": 100, \"content\": \"굵은글씨\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.pageNumber").value(1)) + .andExpect(jsonPath("$.x").value(100)) + .andExpect(jsonPath("$.y").value(200)) + .andExpect(jsonPath("$.width").value(300)) + .andExpect(jsonPath("$.height").value(100)) + .andExpect(jsonPath("$.content").value("굵은글씨")); + } + + @Test + void testGetAnnotations_success() throws Exception { + LocalDateTime now = LocalDateTime.now(); + + // Mock 데이터 설정 + List responses = List.of( + new AnnotationResponse(1L, 1L, 1, 100, 200, 300, 100, "굵은글씨 그냥 글씨 이탤릭체", now.toString(), now.toString()), + new AnnotationResponse(2L, 1L, 2, 150, 250, 350, 120, "", now.toString(), now.toString()) + ); + + when(annotationQueryService.getAnnotationsByDocumentAndPageNumbers(anyLong(), anyList())).thenReturn(responses); + + mockMvc.perform(get("/api/documents/1/annotations?pageNumbers=1,2") + .contentType("application/json")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(1L)) + .andExpect(jsonPath("$[0].pageNumber").value(1)) + .andExpect(jsonPath("$[0].x").value(100)) + .andExpect(jsonPath("$[0].y").value(200)) + .andExpect(jsonPath("$[0].width").value(300)) + .andExpect(jsonPath("$[0].height").value(100)) + .andExpect(jsonPath("$[0].content").value("굵은글씨 그냥 글씨 이탤릭체")) + .andExpect(jsonPath("$[0].createdAt").value(now.toString())) + .andExpect(jsonPath("$[0].updatedAt").value(now.toString())) + .andExpect(jsonPath("$[1].id").value(2L)) + .andExpect(jsonPath("$[1].pageNumber").value(2)) + .andExpect(jsonPath("$[1].x").value(150)) + .andExpect(jsonPath("$[1].y").value(250)) + .andExpect(jsonPath("$[1].width").value(350)) + .andExpect(jsonPath("$[1].height").value(120)); + } + + + @Test + void testUpdateAnnotation_success() throws Exception { + LocalDateTime now = LocalDateTime.now(); + AnnotationResponse response = new AnnotationResponse(1L, 1L, 1, 150, 250, 350, 120, "수정된 주석", now.toString(), now.toString()); + + when(annotationService.updateAnnotation(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), anyInt(), anyString())) + .thenReturn(response); + + mockMvc.perform(put("/api/documents/1/annotations/1") + .contentType("application/json") + .content("{\"pageNumber\": 1, \"x\": 150, \"y\": 250, \"width\": 350, \"height\": 120, \"content\": \"수정된 주석\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.pageNumber").value(1)) + .andExpect(jsonPath("$.content").value("수정된 주석")) + .andExpect(jsonPath("$.x").value(150)) + .andExpect(jsonPath("$.y").value(250)) + .andExpect(jsonPath("$.width").value(350)) + .andExpect(jsonPath("$.height").value(120)); + } + + + @Test + void testDeleteAnnotation_success() throws Exception { + doNothing().when(annotationService).deleteAnnotation(anyLong(), anyLong()); + + mockMvc.perform(delete("/api/documents/1/annotations/1") + .contentType("application/json")) + .andExpect(status().isNoContent()); + + verify(annotationService, times(1)).deleteAnnotation(1L, 1L); + } +} diff --git a/src/test/java/notai/client/oauth/kakao/KakaoOauthClientTest.java b/src/test/java/notai/client/oauth/kakao/KakaoOauthClientTest.java index 2dd7dcb..880eb1b 100644 --- a/src/test/java/notai/client/oauth/kakao/KakaoOauthClientTest.java +++ b/src/test/java/notai/client/oauth/kakao/KakaoOauthClientTest.java @@ -2,17 +2,16 @@ import notai.member.domain.Member; import notai.member.domain.OauthProvider; +import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; +import static org.mockito.Mockito.when; import org.mockito.MockitoAnnotations; import java.time.LocalDateTime; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.when; - public class KakaoOauthClientTest { @Mock @@ -39,12 +38,12 @@ public void testFetchMember() { String nickname = "nickname"; KakaoMemberResponse.Profile profile = new KakaoMemberResponse.Profile(nickname); - KakaoMemberResponse.KakaoAccount kakaoAccount = new KakaoMemberResponse.KakaoAccount( - profile, + KakaoMemberResponse.KakaoAccount kakaoAccount = new KakaoMemberResponse.KakaoAccount(profile, emailNeedsAgreement, isEmailValid, isEmailVerified, - email); + email + ); KakaoMemberResponse kakaoMemberResponse = new KakaoMemberResponse(id, hasSignedUp, connectedAt, kakaoAccount); diff --git a/src/test/java/notai/document/application/DocumentServiceTest.java b/src/test/java/notai/document/application/DocumentServiceTest.java new file mode 100644 index 0000000..6b82bfd --- /dev/null +++ b/src/test/java/notai/document/application/DocumentServiceTest.java @@ -0,0 +1,13 @@ +package notai.document.application; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DocumentServiceTest { + + @InjectMocks + PdfService pdfService; + +} diff --git a/src/test/java/notai/document/application/PdfServiceTest.java b/src/test/java/notai/document/application/PdfServiceTest.java new file mode 100644 index 0000000..70f2397 --- /dev/null +++ b/src/test/java/notai/document/application/PdfServiceTest.java @@ -0,0 +1,73 @@ +package notai.document.application; + +import net.sourceforge.tess4j.Tesseract; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +@ExtendWith(MockitoExtension.class) +class PdfServiceTest { + + @InjectMocks + PdfService pdfService; + + static final String STORAGE_DIR = "src/main/resources/pdf/"; + + @Test + void savePdf_success_existsTestPdf() throws IOException { + //given + ClassPathResource existsPdf = new ClassPathResource("pdf/test.pdf"); + MockMultipartFile mockFile = new MockMultipartFile("file", + existsPdf.getFilename(), + "application/pdf", + Files.readAllBytes(existsPdf.getFile().toPath()) + ); + //when + String savedFileName = pdfService.savePdf(mockFile); + //then + Path savedFilePath = Paths.get(STORAGE_DIR, savedFileName); + Assertions.assertThat(Files.exists(savedFilePath)).isTrue(); + + System.setProperty("jna.library.path", "/usr/local/opt/tesseract/lib/"); + //window, mac -> brew install tesseract, tesseract-lang + Tesseract tesseract = new Tesseract(); + + tesseract.setDatapath("/usr/local/share/tessdata"); + tesseract.setLanguage("kor+eng"); + + try { + PDDocument pdDocument = Loader.loadPDF(savedFilePath.toFile()); + PDFRenderer pdfRenderer = new PDFRenderer(pdDocument); + + var image = pdfRenderer.renderImage(9); + var start = System.currentTimeMillis(); + var ocrResult = tesseract.doOCR(image); + System.out.println("result : " + ocrResult); + var end = System.currentTimeMillis(); + System.out.println(end - start); + pdDocument.close(); + } catch (Exception e) { + e.printStackTrace(); + } + + deleteFile(savedFilePath); + } + + void deleteFile(Path filePath) throws IOException { + if (Files.exists(filePath)) { + Files.delete(filePath); + } + } +} diff --git a/src/test/java/notai/folder/application/FolderQueryServiceTest.java b/src/test/java/notai/folder/application/FolderQueryServiceTest.java new file mode 100644 index 0000000..a567e1d --- /dev/null +++ b/src/test/java/notai/folder/application/FolderQueryServiceTest.java @@ -0,0 +1,64 @@ +package notai.folder.application; + +import notai.folder.application.result.FolderFindResult; +import notai.folder.domain.Folder; +import notai.folder.domain.FolderRepository; +import notai.member.domain.Member; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import static org.mockito.ArgumentMatchers.any; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import static org.mockito.Mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +@ExtendWith(MockitoExtension.class) +class FolderQueryServiceTest { + + @Mock + private FolderRepository folderRepository; + @InjectMocks + private FolderQueryService folderQueryService; + + @Test + @DisplayName("루트 폴더 조회") + void getFolders_success_parentFolderIdIsNull() { + //given + Folder folder = getFolder(1L, null, "루트폴더"); + List expectedResults = List.of(folder); + + when(folderRepository.findAllByMemberIdAndParentFolderIsNull(any(Long.class))).thenReturn(expectedResults); + //when + List folders = folderQueryService.getFolders(1L, null); + + Assertions.assertThat(folders.size()).isEqualTo(1); + } + + @Test + @DisplayName("계층적 구조의 폴더 조회") + void getFolders_success_parentFolderId() { + //given + Folder folder1 = getFolder(1L, null, "루트폴더"); + Folder folder2 = getFolder(2L, folder1, "서브폴더"); + Folder folder3 = getFolder(3L, folder1, "서브폴더"); + List expectedResults = List.of(folder2, folder3); + + when(folderRepository.findAllByMemberIdAndParentFolderId(any(Long.class), any(Long.class))).thenReturn( + expectedResults); + //when + List folders = folderQueryService.getFolders(1L, 1L); + + Assertions.assertThat(folders.size()).isEqualTo(2); + } + + private Folder getFolder(Long id, Folder parentFolder, String name) { + Member member = mock(Member.class); + Folder folder = spy(new Folder(member, name, parentFolder)); + lenient().when(folder.getId()).thenReturn(id); + return folder; + } +} diff --git a/src/test/java/notai/llm/application/LLMServiceTest.java b/src/test/java/notai/llm/application/LLMServiceTest.java index 8d85bee..4919144 100644 --- a/src/test/java/notai/llm/application/LLMServiceTest.java +++ b/src/test/java/notai/llm/application/LLMServiceTest.java @@ -19,7 +19,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; -import java.util.Optional; import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; @@ -53,11 +52,11 @@ class LLMServiceTest { List pages = List.of(1, 2, 3); LLMSubmitCommand command = new LLMSubmitCommand(documentId, pages); - given(documentRepository.findById(anyLong())).willReturn(Optional.empty()); + given(documentRepository.getById(anyLong())).willThrow(NotFoundException.class); // when & then assertAll(() -> assertThrows(NotFoundException.class, () -> llmService.submitTask(command)), - () -> verify(documentRepository, times(1)).findById(documentId), + () -> verify(documentRepository, times(1)).getById(documentId), () -> verify(llmRepository, never()).save(any(LLM.class)) ); } @@ -70,13 +69,13 @@ class LLMServiceTest { LLMSubmitCommand command = new LLMSubmitCommand(documentId, pages); Document document = mock(Document.class); - given(documentRepository.findById(anyLong())).willReturn(Optional.of(document)); + given(documentRepository.getById(anyLong())).willReturn(document); given(llmRepository.save(any(LLM.class))).willAnswer(invocation -> invocation.getArgument(0)); // when LLMSubmitResult result = llmService.submitTask(command); // then - assertAll(() -> verify(documentRepository, times(1)).findById(anyLong()), + assertAll(() -> verify(documentRepository, times(1)).getById(anyLong()), () -> verify(llmRepository, times(3)).save(any(LLM.class)) ); } diff --git a/src/test/java/notai/pageRecording/application/PageRecordingServiceTest.java b/src/test/java/notai/pageRecording/application/PageRecordingServiceTest.java new file mode 100644 index 0000000..3d89a01 --- /dev/null +++ b/src/test/java/notai/pageRecording/application/PageRecordingServiceTest.java @@ -0,0 +1,54 @@ +package notai.pageRecording.application; + +import notai.pageRecording.application.command.PageRecordingSaveCommand; +import notai.pageRecording.application.command.PageRecordingSaveCommand.PageRecordingSession; +import notai.pageRecording.domain.PageRecording; +import notai.pageRecording.domain.PageRecordingRepository; +import notai.recording.domain.Recording; +import notai.recording.domain.RecordingRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PageRecordingServiceTest { + + @InjectMocks + private PageRecordingService pageRecordingService; + + @Mock + private PageRecordingRepository pageRecordingRepository; + + @Mock + private RecordingRepository recordingRepository; + + @Test + void 페이지_넘김_이벤트에_따라_페이지별_녹음_시간을_저장() { + // given + Long recordingId = 1L; + Long documentId = 1L; + + PageRecordingSaveCommand command = new PageRecordingSaveCommand( + recordingId, + documentId, + List.of(new PageRecordingSession(1, 100.0, 185.5), new PageRecordingSession(5, 185.5, 290.3)) + ); + + Recording foundRecording = mock(Recording.class); + given(recordingRepository.getById(recordingId)).willReturn(foundRecording); + given(foundRecording.isRecordingOwnedByDocument(documentId)).willReturn(true); + + // when + pageRecordingService.savePageRecording(command); + + // then + verify(pageRecordingRepository, times(2)).save(any(PageRecording.class)); + } +} \ No newline at end of file diff --git a/src/test/java/notai/recording/application/RecordingServiceTest.java b/src/test/java/notai/recording/application/RecordingServiceTest.java new file mode 100644 index 0000000..c97dde0 --- /dev/null +++ b/src/test/java/notai/recording/application/RecordingServiceTest.java @@ -0,0 +1,103 @@ +package notai.recording.application; + +import notai.common.domain.vo.FilePath; +import notai.common.exception.type.BadRequestException; +import notai.common.utils.AudioDecoder; +import notai.common.utils.FileManager; +import notai.document.domain.Document; +import notai.document.domain.DocumentRepository; +import notai.recording.application.command.RecordingSaveCommand; +import notai.recording.application.result.RecordingSaveResult; +import notai.recording.domain.Recording; +import notai.recording.domain.RecordingRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +class RecordingServiceTest { + + @InjectMocks + private RecordingService recordingService; + + @Mock + private RecordingRepository recordingRepository; + + @Mock + private DocumentRepository documentRepository; + + @Spy + private final AudioDecoder audioDecoder = new AudioDecoder(); + + @Spy + private final FileManager fileManager = new FileManager(); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(recordingService, "audioBasePath", "src/main/resources/audio/"); + } + + @Test + void 녹음_파일_업로드시_잘못된_데이터인_경우_예외발생() { + // given + Long documentId = 1L; + String invalidAudioData = "data:audio/mpeg;base64,!!!"; + + Document document = mock(Document.class); + RecordingSaveCommand command = new RecordingSaveCommand(documentId, invalidAudioData); + + Recording savedRecording = new Recording(document); + ReflectionTestUtils.setField(savedRecording, "id", 1L); + + given(documentRepository.getById(anyLong())).willReturn(document); + given(recordingRepository.save(any(Recording.class))).willReturn(savedRecording); + given(document.getName()).willReturn("안녕하세요백종원입니다"); + + // when & then + assertThrows(BadRequestException.class, () -> { + recordingService.saveRecording(command); + }); + } + + @Test + void 녹음_파일_업로드() { + // given + Long documentId = 1L; + String base64AudioData = + "data:audio/mpeg;base64,SUQzAwAAAAAfdlRJVDIAAAANAAAB//6BrdmzIAAzADMAVFBFMQAAAAEAAABUQUxCAAAAAQAAAFRZRVIAAAABAAAAVENPTgAAAAEAAABUUkNLAAAAAQAAAENPTU0AAAAfAAAAZW5nAG9ubGluZS1hdWRpby1jb252ZXJ0ZXIuY29tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVQAACBZVSfG6URvoYq1haZX0KWBemcSySQ6XIy+ZNyy2Oh/9n8stmRk1ks+yyVDL/vZ9lsvmoZVToqKDQ7Oz//7sYqKqHZ2MqIqLoqJ///+ir9FRF0sb8xQwOpVMQU1FMy4xMDBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+5Jkbg/y0me60eMUcgAADSAAAAEAAAGkAAAAIAAANIAAAARVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV"; + + Document document = mock(Document.class); + RecordingSaveCommand command = new RecordingSaveCommand(documentId, base64AudioData); + + Recording savedRecording = new Recording(document); + ReflectionTestUtils.setField(savedRecording, "id", 1L); + + given(documentRepository.getById(anyLong())).willReturn(document); + given(recordingRepository.save(any(Recording.class))).willReturn(savedRecording); + given(document.getName()).willReturn("안녕하세요백종원입니다"); + + // when + RecordingSaveResult result = recordingService.saveRecording(command); + + // then + FilePath filePath = FilePath.from("안녕하세요백종원입니다_1.mp3"); + Path expectedFilePath = Paths.get("src/main/resources/audio/" + filePath.getFilePath()); + + assertAll(() -> assertTrue(Files.exists(expectedFilePath)), () -> assertTrue(Files.size(expectedFilePath) > 0)); + } +} \ No newline at end of file From 751e5cd111a5065eb9f1ef186cea7dc2f58814c1 Mon Sep 17 00:00:00 2001 From: yugyeom <48901587+rladbrua0207@users.noreply.github.com> Date: Fri, 11 Oct 2024 23:04:31 +0900 Subject: [PATCH 10/12] =?UTF-8?q?Feat:=20AI=20Client=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: AI Client기능 구현 * Test: AI Client 통합테스트 작성 * build: 빌드시 테스트에서 제외해야 할 테스트 태그 추가 * Test: AI Client 단위테스트 작성 * Feat: LLMService에 AI 서버 요청 기능 추가 * Test: LLMService AI_기능_요청 메서드에 클라이언트 단위테스트 코드 추가 * Chore: 불필요한 주석 제거 * Chore: 더미 AI서버 주소 추가 * Chore: 불필요한 impl클래스 제거 및 테스트코드 수정 --- build.gradle | 6 ++ .../domain/AnnotationRepository.java | 4 ++ src/main/java/notai/auth/Auth.java | 5 +- src/main/java/notai/client/ai/AiClient.java | 18 ++++++ .../java/notai/client/ai/AiClientConfig.java | 32 +++++++++++ .../client/ai/request/LlmTaskRequest.java | 11 ++++ .../client/ai/request/SttTaskRequest.java | 8 +++ .../client/ai/response/TaskResponse.java | 9 +++ .../java/notai/common/config/AuthConfig.java | 6 +- .../notai/common/config/AuthInterceptor.java | 3 +- .../java/notai/common/domain/RootEntity.java | 3 +- .../notai/llm/application/LLMService.java | 29 ++++++++-- .../application/result/MemberFindResult.java | 3 +- .../response/MemberFindResponse.java | 3 +- src/main/resources/application-local.yml | 1 + .../client/ai/AiClientIntegrationTest.java | 49 ++++++++++++++++ .../java/notai/client/ai/AiClientTest.java | 56 ++++++++++++++++++ .../notai/llm/application/LLMServiceTest.java | 57 +++++++++++++++---- 18 files changed, 277 insertions(+), 26 deletions(-) create mode 100644 src/main/java/notai/client/ai/AiClient.java create mode 100644 src/main/java/notai/client/ai/AiClientConfig.java create mode 100644 src/main/java/notai/client/ai/request/LlmTaskRequest.java create mode 100644 src/main/java/notai/client/ai/request/SttTaskRequest.java create mode 100644 src/main/java/notai/client/ai/response/TaskResponse.java create mode 100644 src/test/java/notai/client/ai/AiClientIntegrationTest.java create mode 100644 src/test/java/notai/client/ai/AiClientTest.java diff --git a/build.gradle b/build.gradle index 344ed4f..79dab35 100644 --- a/build.gradle +++ b/build.gradle @@ -64,3 +64,9 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +test { + useJUnitPlatform { + excludeTags 'exclude-test' + } +} diff --git a/src/main/java/notai/annotation/domain/AnnotationRepository.java b/src/main/java/notai/annotation/domain/AnnotationRepository.java index c05ab3c..5ff2bfa 100644 --- a/src/main/java/notai/annotation/domain/AnnotationRepository.java +++ b/src/main/java/notai/annotation/domain/AnnotationRepository.java @@ -17,4 +17,8 @@ default Annotation getById(Long annotationId) { return findById(annotationId) .orElseThrow(() -> new NotFoundException("주석을 찾을 수 없습니다. ID: " + annotationId)); } + + List findByDocumentIdAndPageNumber(Long documentId, Integer pageNumber); + + List findByDocumentId(Long documentId); } diff --git a/src/main/java/notai/auth/Auth.java b/src/main/java/notai/auth/Auth.java index 62c5c00..d3eedd0 100644 --- a/src/main/java/notai/auth/Auth.java +++ b/src/main/java/notai/auth/Auth.java @@ -1,12 +1,13 @@ package notai.auth; import io.swagger.v3.oas.annotations.Hidden; -import static java.lang.annotation.ElementType.PARAMETER; -import static java.lang.annotation.RetentionPolicy.RUNTIME; import java.lang.annotation.Retention; import java.lang.annotation.Target; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + @Hidden @Target(PARAMETER) @Retention(RUNTIME) diff --git a/src/main/java/notai/client/ai/AiClient.java b/src/main/java/notai/client/ai/AiClient.java new file mode 100644 index 0000000..296787a --- /dev/null +++ b/src/main/java/notai/client/ai/AiClient.java @@ -0,0 +1,18 @@ +package notai.client.ai; + +import notai.client.ai.request.LlmTaskRequest; +import notai.client.ai.response.TaskResponse; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.service.annotation.PostExchange; + +public interface AiClient { + + @PostExchange(url = "/api/ai/llm") + TaskResponse submitLlmTask(@RequestBody LlmTaskRequest request); + + @PostExchange(url = "/api/ai/stt") + TaskResponse submitSttTask(@RequestPart("audio") MultipartFile audioFile); +} + diff --git a/src/main/java/notai/client/ai/AiClientConfig.java b/src/main/java/notai/client/ai/AiClientConfig.java new file mode 100644 index 0000000..88b17f9 --- /dev/null +++ b/src/main/java/notai/client/ai/AiClientConfig.java @@ -0,0 +1,32 @@ +package notai.client.ai; + +import lombok.extern.slf4j.Slf4j; +import static notai.client.HttpInterfaceUtil.createHttpInterface; +import notai.common.exception.type.ExternalApiException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.client.RestClient; + +@Slf4j +@Configuration +public class AiClientConfig { + + @Value("${ai-server-url}") + private String aiServerUrl; + + @Bean + public AiClient aiClient() { + RestClient restClient = + RestClient.builder().baseUrl(aiServerUrl).requestInterceptor((request, body, execution) -> { + request.getHeaders().setContentLength(body.length); // Content-Length 설정 안하면 411 에러 발생 + return execution.execute(request, body); + }).defaultStatusHandler(HttpStatusCode::isError, (request, response) -> { + String responseBody = new String(response.getBody().readAllBytes()); + log.error("Response Status: {}", response.getStatusCode()); + throw new ExternalApiException(responseBody, response.getStatusCode().value()); + }).build(); + return createHttpInterface(restClient, AiClient.class); + } +} diff --git a/src/main/java/notai/client/ai/request/LlmTaskRequest.java b/src/main/java/notai/client/ai/request/LlmTaskRequest.java new file mode 100644 index 0000000..c44ae23 --- /dev/null +++ b/src/main/java/notai/client/ai/request/LlmTaskRequest.java @@ -0,0 +1,11 @@ +package notai.client.ai.request; + +public record LlmTaskRequest( + String ocrText, + String stt, + String keyboardNote +) { + public static LlmTaskRequest of(String ocrText, String stt, String keyboardNote) { + return new LlmTaskRequest(ocrText, stt, keyboardNote); + } +} diff --git a/src/main/java/notai/client/ai/request/SttTaskRequest.java b/src/main/java/notai/client/ai/request/SttTaskRequest.java new file mode 100644 index 0000000..81e8bfa --- /dev/null +++ b/src/main/java/notai/client/ai/request/SttTaskRequest.java @@ -0,0 +1,8 @@ +package notai.client.ai.request; + +import org.springframework.web.multipart.MultipartFile; + +public record SttTaskRequest( + MultipartFile audioFile +) { +} diff --git a/src/main/java/notai/client/ai/response/TaskResponse.java b/src/main/java/notai/client/ai/response/TaskResponse.java new file mode 100644 index 0000000..3105145 --- /dev/null +++ b/src/main/java/notai/client/ai/response/TaskResponse.java @@ -0,0 +1,9 @@ +package notai.client.ai.response; + +import java.util.UUID; + +public record TaskResponse( + UUID taskId, + String taskType +) { +} diff --git a/src/main/java/notai/common/config/AuthConfig.java b/src/main/java/notai/common/config/AuthConfig.java index c0e8bc9..6e4417f 100644 --- a/src/main/java/notai/common/config/AuthConfig.java +++ b/src/main/java/notai/common/config/AuthConfig.java @@ -17,8 +17,10 @@ public class AuthConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(authInterceptor).addPathPatterns("/api/**").excludePathPatterns( - "/api/members/oauth/login/**").excludePathPatterns("/api/members/token/refresh"); + registry.addInterceptor(authInterceptor) + .addPathPatterns("/api/**") + .excludePathPatterns("/api/members/oauth/login/**") + .excludePathPatterns("/api/members/token/refresh"); } @Override diff --git a/src/main/java/notai/common/config/AuthInterceptor.java b/src/main/java/notai/common/config/AuthInterceptor.java index 327a0f2..87c168c 100644 --- a/src/main/java/notai/common/config/AuthInterceptor.java +++ b/src/main/java/notai/common/config/AuthInterceptor.java @@ -3,10 +3,11 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import notai.auth.TokenService; -import static org.springframework.http.HttpHeaders.AUTHORIZATION; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + @Component public class AuthInterceptor implements HandlerInterceptor { private final TokenService tokenService; diff --git a/src/main/java/notai/common/domain/RootEntity.java b/src/main/java/notai/common/domain/RootEntity.java index 7fcd71b..c8220c2 100644 --- a/src/main/java/notai/common/domain/RootEntity.java +++ b/src/main/java/notai/common/domain/RootEntity.java @@ -2,7 +2,6 @@ import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; -import static lombok.AccessLevel.PROTECTED; import lombok.Getter; import lombok.NoArgsConstructor; import org.springframework.data.annotation.CreatedDate; @@ -13,6 +12,8 @@ import java.time.LocalDateTime; import java.util.Objects; +import static lombok.AccessLevel.PROTECTED; + @Getter @NoArgsConstructor(access = PROTECTED) @EntityListeners(AuditingEntityListener.class) diff --git a/src/main/java/notai/llm/application/LLMService.java b/src/main/java/notai/llm/application/LLMService.java index d8a395f..6986a3b 100644 --- a/src/main/java/notai/llm/application/LLMService.java +++ b/src/main/java/notai/llm/application/LLMService.java @@ -1,6 +1,11 @@ package notai.llm.application; +import static java.util.stream.Collectors.groupingBy; import lombok.RequiredArgsConstructor; +import notai.annotation.domain.Annotation; +import notai.annotation.domain.AnnotationRepository; +import notai.client.ai.AiClient; +import notai.client.ai.request.LlmTaskRequest; import notai.document.domain.Document; import notai.document.domain.DocumentRepository; import notai.llm.application.command.LLMSubmitCommand; @@ -16,7 +21,10 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; /** * SummaryService 와 ExamService 는 엔티티와 관련된 로직만 처리하고 @@ -32,12 +40,24 @@ public class LLMService { private final DocumentRepository documentRepository; private final SummaryRepository summaryRepository; private final ProblemRepository problemRepository; + private final AnnotationRepository annotationRepository; + private final AiClient aiClient; public LLMSubmitResult submitTask(LLMSubmitCommand command) { Document foundDocument = documentRepository.getById(command.documentId()); + List annotations = annotationRepository.findByDocumentId(command.documentId()); + + Map> annotationsByPage = + annotations.stream().collect(groupingBy(Annotation::getPageNumber)); command.pages().forEach(pageNumber -> { - UUID taskId = sendRequestToAIServer(); + String annotationContents = annotationsByPage.getOrDefault( + pageNumber, + List.of() + ).stream().map(Annotation::getContent).collect(Collectors.joining(", ")); + + // Todo OCR, STT 결과 전달 + UUID taskId = sendRequestToAIServer("ocrText", "stt", annotationContents); Summary summary = new Summary(foundDocument, pageNumber); Problem problem = new Problem(foundDocument, pageNumber); @@ -64,10 +84,7 @@ public Integer updateSummaryAndProblem(SummaryAndProblemUpdateCommand command) { return command.pageNumber(); } - /** - * 임시 값 반환, 추후 AI 서버에서 작업 단위 UUID 가 반환됨. - */ - private UUID sendRequestToAIServer() { - return UUID.randomUUID(); + private UUID sendRequestToAIServer(String ocrText, String stt, String keyboardNote) { + return aiClient.submitLlmTask(LlmTaskRequest.of(ocrText, stt, keyboardNote)).taskId(); } } diff --git a/src/main/java/notai/member/application/result/MemberFindResult.java b/src/main/java/notai/member/application/result/MemberFindResult.java index 261e83e..13b2169 100644 --- a/src/main/java/notai/member/application/result/MemberFindResult.java +++ b/src/main/java/notai/member/application/result/MemberFindResult.java @@ -3,7 +3,8 @@ import notai.member.domain.Member; public record MemberFindResult( - Long id, String nickname + Long id, + String nickname ) { public static MemberFindResult from(Member member) { return new MemberFindResult(member.getId(), member.getNickname()); diff --git a/src/main/java/notai/member/presentation/response/MemberFindResponse.java b/src/main/java/notai/member/presentation/response/MemberFindResponse.java index a1b8115..ebd525b 100644 --- a/src/main/java/notai/member/presentation/response/MemberFindResponse.java +++ b/src/main/java/notai/member/presentation/response/MemberFindResponse.java @@ -3,7 +3,8 @@ import notai.member.application.result.MemberFindResult; public record MemberFindResponse( - Long id, String nickname + Long id, + String nickname ) { public static MemberFindResponse from(MemberFindResult result) { return new MemberFindResponse(result.id(), result.nickname()); diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index ba990f8..da70f6b 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -37,6 +37,7 @@ server: force: true server-url: http://localhost:8080 +ai-server-url: http://localhost:5000 # 실제 AI 서버주소는 prod에서만 사용 token: # todo production에서 secretKey 변경 secretKey: "ZGQrT0tuZHZkRWRxeXJCamRYMDFKMnBaR2w5WXlyQm9HU2RqZHNha1gycFlkMWpLc0dObw==" diff --git a/src/test/java/notai/client/ai/AiClientIntegrationTest.java b/src/test/java/notai/client/ai/AiClientIntegrationTest.java new file mode 100644 index 0000000..ebd27d3 --- /dev/null +++ b/src/test/java/notai/client/ai/AiClientIntegrationTest.java @@ -0,0 +1,49 @@ +package notai.client.ai; + +import notai.client.ai.request.LlmTaskRequest; +import notai.client.ai.response.TaskResponse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockMultipartFile; + +@SpringBootTest +@Tag("exclude-test") // 테스트 필요할때 주석 +class AiClientIntegrationTest { + + @Autowired + private AiClient aiClient; + + @Test + void LLM_태스크_제출_통합_테스트() { + // Given + LlmTaskRequest request = LlmTaskRequest.of("OCR 텍스트", "STT 텍스트", "키보드 노트"); + + // When + TaskResponse response = aiClient.submitLlmTask(request); + + // Then + assertNotNull(response); + assertNotNull(response.taskId()); + assertEquals("llm", response.taskType()); + } + + @Test + void STT_태스크_제출_통합_테스트() { + // Given + MockMultipartFile audioFile = new MockMultipartFile( + "audio", "test.mp3", "audio/mpeg", "test audio content".getBytes() + ); + + // When + TaskResponse response = aiClient.submitSttTask(audioFile); + + // Then + assertNotNull(response); + assertNotNull(response.taskId()); + assertEquals("llm", response.taskType()); + } +} diff --git a/src/test/java/notai/client/ai/AiClientTest.java b/src/test/java/notai/client/ai/AiClientTest.java new file mode 100644 index 0000000..2fab457 --- /dev/null +++ b/src/test/java/notai/client/ai/AiClientTest.java @@ -0,0 +1,56 @@ +package notai.client.ai; + +import notai.client.ai.request.LlmTaskRequest; +import notai.client.ai.response.TaskResponse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import static org.mockito.Mockito.*; +import org.mockito.MockitoAnnotations; +import org.springframework.web.multipart.MultipartFile; + +import java.util.UUID; + +class AiClientTest { + + @Mock + private AiClient aiClient; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void LLM_테스크_전달_테스트() { + // Given + LlmTaskRequest request = LlmTaskRequest.of("OCR 텍스트", "STT 텍스트", "키보드 노트"); + UUID expectedTaskId = UUID.randomUUID(); + TaskResponse expectedResponse = new TaskResponse(expectedTaskId, "llm"); + when(aiClient.submitLlmTask(request)).thenReturn(expectedResponse); + + // When + TaskResponse response = aiClient.submitLlmTask(request); + + // Then + assertEquals(expectedResponse, response); + verify(aiClient, times(1)).submitLlmTask(request); + } + + @Test + void STT_테스크_전달_테스트() { + // Given + MultipartFile mockAudioFile = mock(MultipartFile.class); + UUID expectedTaskId = UUID.randomUUID(); + TaskResponse expectedResponse = new TaskResponse(expectedTaskId, "stt"); + when(aiClient.submitSttTask(mockAudioFile)).thenReturn(expectedResponse); + + // When + TaskResponse response = aiClient.submitSttTask(mockAudioFile); + + // Then + assertEquals(expectedResponse, response); + verify(aiClient, times(1)).submitSttTask(mockAudioFile); + } +} diff --git a/src/test/java/notai/llm/application/LLMServiceTest.java b/src/test/java/notai/llm/application/LLMServiceTest.java index 4919144..2523c47 100644 --- a/src/test/java/notai/llm/application/LLMServiceTest.java +++ b/src/test/java/notai/llm/application/LLMServiceTest.java @@ -1,32 +1,40 @@ package notai.llm.application; +import notai.annotation.domain.Annotation; +import notai.annotation.domain.AnnotationRepository; +import notai.client.ai.AiClient; +import notai.client.ai.request.LlmTaskRequest; +import notai.client.ai.response.TaskResponse; import notai.common.exception.type.NotFoundException; import notai.document.domain.Document; import notai.document.domain.DocumentRepository; +import notai.folder.domain.Folder; import notai.llm.application.command.LLMSubmitCommand; import notai.llm.application.command.SummaryAndProblemUpdateCommand; import notai.llm.application.result.LLMSubmitResult; import notai.llm.domain.LLM; import notai.llm.domain.LLMRepository; +import notai.member.domain.Member; +import notai.member.domain.OauthId; +import notai.member.domain.OauthProvider; import notai.problem.domain.Problem; import notai.problem.domain.ProblemRepository; import notai.summary.domain.Summary; import notai.summary.domain.SummaryRepository; +import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; import org.mockito.InjectMocks; import org.mockito.Mock; +import static org.mockito.Mockito.*; import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; import java.util.UUID; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.*; - @ExtendWith(MockitoExtension.class) class LLMServiceTest { @@ -45,6 +53,12 @@ class LLMServiceTest { @Mock private ProblemRepository problemRepository; + @Mock + private AnnotationRepository annotationRepository; + + @Mock + private AiClient aiClient; + @Test void AI_기능_요청시_존재하지_않는_문서ID로_요청한_경우_예외_발생() { // given @@ -62,22 +76,41 @@ class LLMServiceTest { } @Test - void AI_기능_요청() { + void AI_기능_요청_및_AI_클라이언트_테스트() { // given Long documentId = 1L; - List pages = List.of(1, 2, 3); + List pages = List.of(1, 2); LLMSubmitCommand command = new LLMSubmitCommand(documentId, pages); - Document document = mock(Document.class); + + Member member = new Member(new OauthId("12345", OauthProvider.KAKAO), "test@example.com", "TestUser"); + Folder folder = new Folder(member, "TestFolder"); + Document document = new Document(folder, "TestDocument", "http://example.com/test.pdf"); + + List annotations = List.of(new Annotation(document, 1, 10, 20, 100, 50, "Annotation 1"), + new Annotation(document, 1, 30, 40, 80, 60, "Annotation 2"), + new Annotation(document, 2, 50, 60, 120, 70, "Annotation 3") + ); + + UUID taskId = UUID.randomUUID(); + TaskResponse taskResponse = new TaskResponse(taskId, "llm"); given(documentRepository.getById(anyLong())).willReturn(document); + given(annotationRepository.findByDocumentId(anyLong())).willReturn(annotations); + given(aiClient.submitLlmTask(any(LlmTaskRequest.class))).willReturn(taskResponse); given(llmRepository.save(any(LLM.class))).willAnswer(invocation -> invocation.getArgument(0)); + // when LLMSubmitResult result = llmService.submitTask(command); // then - assertAll(() -> verify(documentRepository, times(1)).getById(anyLong()), - () -> verify(llmRepository, times(3)).save(any(LLM.class)) + assertAll(() -> verify(documentRepository, times(1)).getById(documentId), + () -> verify(annotationRepository, times(1)).findByDocumentId(documentId), + () -> verify(aiClient, times(2)).submitLlmTask(any(LlmTaskRequest.class)), + () -> verify(llmRepository, times(2)).save(any(LLM.class)) ); + + verify(aiClient).submitLlmTask(argThat(request -> request.keyboardNote().equals("Annotation 1, Annotation 2"))); + verify(aiClient).submitLlmTask(argThat(request -> request.keyboardNote().equals("Annotation 3"))); } @Test @@ -122,4 +155,4 @@ class LLMServiceTest { () -> assertEquals(pageNumber, resultPageNumber) ); } -} \ No newline at end of file +} From 311db97be80106a67d054f720f7315b6c0cec24e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=ED=9B=88?= <76200940+yunjunghun0116@users.noreply.github.com> Date: Fri, 11 Oct 2024 23:05:41 +0900 Subject: [PATCH 11/12] =?UTF-8?q?Feat:=20PDF=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20OCR=20=EA=B8=B0=EB=8A=A5=20(#1?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: OCR 도메인 생성 도메인 생성 * feat: ID를 사용하여 엔티티 꺼내오는 작업 수행 엔티티 꺼내오는 작업 수행 * rename: PDF 관련 파일 이동 파일 이동 * feat: 문서 저장시 OCR 작업수행하는 기능 OCR 자동 작업 기능 구현 * refactor: PDF 저장 로직 개선 저장 로직 개선 * test: OCR 기능 작동 여부 테스트코드 작성 테스트 코드 작성 * refactor: 상수값 static 으로 따로 관리 상수값 관리하도록 피드백 반영 * feat: AOP 를 활용하여 Get요청시 ok를 바로 보내줄 수 있도록 기능 구현 AOP 활용 래퍼클래스 개발 * remove: 상의 후 도입할지 정해야하기때문에 우선 삭제 --- .../document/application/DocumentService.java | 34 +++++---- .../presentation/DocumentController.java | 7 +- .../notai/ocr/application/OCRService.java | 50 +++++++++++++ src/main/java/notai/ocr/domain/OCR.java | 39 ++++++++++ .../java/notai/ocr/domain/OCRRepository.java | 17 +++++ .../presentation => pdf}/PdfController.java | 3 +- .../application => pdf}/PdfService.java | 7 +- .../java/notai/pdf/result/PdfSaveResult.java | 19 +++++ .../application/DocumentServiceTest.java | 13 ---- .../document/application/PdfServiceTest.java | 73 ------------------- .../notai/ocr/application/OCRServiceTest.java | 49 +++++++++++++ 11 files changed, 205 insertions(+), 106 deletions(-) create mode 100644 src/main/java/notai/ocr/application/OCRService.java create mode 100644 src/main/java/notai/ocr/domain/OCR.java create mode 100644 src/main/java/notai/ocr/domain/OCRRepository.java rename src/main/java/notai/{document/presentation => pdf}/PdfController.java (92%) rename src/main/java/notai/{document/application => pdf}/PdfService.java (88%) create mode 100644 src/main/java/notai/pdf/result/PdfSaveResult.java delete mode 100644 src/test/java/notai/document/application/DocumentServiceTest.java delete mode 100644 src/test/java/notai/document/application/PdfServiceTest.java create mode 100644 src/test/java/notai/ocr/application/OCRServiceTest.java diff --git a/src/main/java/notai/document/application/DocumentService.java b/src/main/java/notai/document/application/DocumentService.java index 1693b4f..7ebf002 100644 --- a/src/main/java/notai/document/application/DocumentService.java +++ b/src/main/java/notai/document/application/DocumentService.java @@ -9,6 +9,9 @@ import notai.document.presentation.request.DocumentUpdateRequest; import notai.folder.domain.Folder; import notai.folder.domain.FolderRepository; +import notai.ocr.application.OCRService; +import notai.pdf.PdfService; +import notai.pdf.result.PdfSaveResult; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -17,28 +20,26 @@ public class DocumentService { private final PdfService pdfService; + private final OCRService ocrService; private final DocumentRepository documentRepository; private final FolderRepository folderRepository; public DocumentSaveResult saveDocument( Long folderId, MultipartFile pdfFile, DocumentSaveRequest documentSaveRequest ) { - String pdfName = pdfService.savePdf(pdfFile); - String pdfUrl = convertPdfUrl(pdfName); - Folder folder = folderRepository.getById(folderId); - Document document = new Document(folder, documentSaveRequest.name(), pdfUrl); - Document savedDocument = documentRepository.save(document); - return DocumentSaveResult.of(savedDocument.getId(), savedDocument.getName(), savedDocument.getUrl()); + PdfSaveResult pdfSaveResult = pdfService.savePdf(pdfFile); + Document document = saveAndReturnDocument(folderId, documentSaveRequest, pdfSaveResult.pdfUrl()); + ocrService.saveOCR(document, pdfSaveResult.pdf()); + return DocumentSaveResult.of(document.getId(), document.getName(), document.getUrl()); } public DocumentSaveResult saveRootDocument( MultipartFile pdfFile, DocumentSaveRequest documentSaveRequest ) { - String pdfName = pdfService.savePdf(pdfFile); - String pdfUrl = convertPdfUrl(pdfName); - Document document = new Document(documentSaveRequest.name(), pdfUrl); - Document savedDocument = documentRepository.save(document); - return DocumentSaveResult.of(savedDocument.getId(), savedDocument.getName(), savedDocument.getUrl()); + PdfSaveResult pdfSaveResult = pdfService.savePdf(pdfFile); + Document document = saveAndReturnRootDocument(documentSaveRequest, pdfSaveResult.pdfUrl()); + ocrService.saveOCR(document, pdfSaveResult.pdf()); + return DocumentSaveResult.of(document.getId(), document.getName(), document.getUrl()); } public DocumentUpdateResult updateDocument( @@ -65,7 +66,14 @@ public void deleteAllByFolder( documentRepository.deleteAllByFolder(folder); } - private String convertPdfUrl(String pdfName) { - return String.format("pdf/%s", pdfName); + private Document saveAndReturnDocument(Long folderId, DocumentSaveRequest documentSaveRequest, String pdfUrl) { + Folder folder = folderRepository.getById(folderId); + Document document = new Document(folder, documentSaveRequest.name(), pdfUrl); + return documentRepository.save(document); + } + + private Document saveAndReturnRootDocument(DocumentSaveRequest documentSaveRequest, String pdfUrl) { + Document document = new Document(documentSaveRequest.name(), pdfUrl); + return documentRepository.save(document); } } diff --git a/src/main/java/notai/document/presentation/DocumentController.java b/src/main/java/notai/document/presentation/DocumentController.java index 2356163..5b5610f 100644 --- a/src/main/java/notai/document/presentation/DocumentController.java +++ b/src/main/java/notai/document/presentation/DocumentController.java @@ -28,6 +28,9 @@ public class DocumentController { private final DocumentService documentService; private final DocumentQueryService documentQueryService; + private static final Long ROOT_FOLDER_ID = -1L; + private static final String FOLDER_URL_FORMAT = "/api/folders/%s/documents/%s"; + @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}) public ResponseEntity saveDocument( @PathVariable Long folderId, @@ -35,13 +38,13 @@ public ResponseEntity saveDocument( @RequestPart DocumentSaveRequest documentSaveRequest ) { DocumentSaveResult documentSaveResult; - if (folderId.equals(-1L)) { + if (folderId.equals(ROOT_FOLDER_ID)) { documentSaveResult = documentService.saveRootDocument(pdfFile, documentSaveRequest); } else { documentSaveResult = documentService.saveDocument(folderId, pdfFile, documentSaveRequest); } DocumentSaveResponse response = DocumentSaveResponse.from(documentSaveResult); - String url = String.format("/api/folders/%s/documents/%s", folderId, response.id()); + String url = String.format(FOLDER_URL_FORMAT, folderId, response.id()); return ResponseEntity.created(URI.create(url)).body(response); } diff --git a/src/main/java/notai/ocr/application/OCRService.java b/src/main/java/notai/ocr/application/OCRService.java new file mode 100644 index 0000000..ee63f5e --- /dev/null +++ b/src/main/java/notai/ocr/application/OCRService.java @@ -0,0 +1,50 @@ +package notai.ocr.application; + +import lombok.RequiredArgsConstructor; +import net.sourceforge.tess4j.Tesseract; +import notai.common.exception.type.FileProcessException; +import notai.document.domain.Document; +import notai.ocr.domain.OCR; +import notai.ocr.domain.OCRRepository; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.awt.image.BufferedImage; +import java.io.File; + +@Service +@RequiredArgsConstructor +public class OCRService { + + private final OCRRepository ocrRepository; + + @Async + public void saveOCR( + Document document, File pdfFile + ) { + try { + System.setProperty("jna.library.path", "/usr/local/opt/tesseract/lib/"); + //window, mac -> brew install tesseract, tesseract-lang + Tesseract tesseract = new Tesseract(); + + tesseract.setDatapath("/usr/local/share/tessdata"); + tesseract.setLanguage("kor+eng"); + + PDDocument pdDocument = Loader.loadPDF(pdfFile); + PDFRenderer pdfRenderer = new PDFRenderer(pdDocument); + for (int i = 0; i < pdDocument.getNumberOfPages(); i++) { + BufferedImage image = pdfRenderer.renderImage(i); + String ocrResult = tesseract.doOCR(image); + OCR ocr = new OCR(document, i + 1, ocrResult); + ocrRepository.save(ocr); + } + + pdDocument.close(); + } catch (Exception e) { + throw new FileProcessException("PDF 파일을 통해 OCR 작업을 수행하는데 실패했습니다."); + } + } +} diff --git a/src/main/java/notai/ocr/domain/OCR.java b/src/main/java/notai/ocr/domain/OCR.java new file mode 100644 index 0000000..cad27e8 --- /dev/null +++ b/src/main/java/notai/ocr/domain/OCR.java @@ -0,0 +1,39 @@ +package notai.ocr.domain; + +import jakarta.persistence.*; +import static jakarta.persistence.GenerationType.IDENTITY; +import jakarta.validation.constraints.NotNull; +import static lombok.AccessLevel.PROTECTED; +import lombok.Getter; +import lombok.NoArgsConstructor; +import notai.common.domain.RootEntity; +import notai.document.domain.Document; + +@Entity +@Table(name = "ocr") +@Getter +@NoArgsConstructor(access = PROTECTED) +public class OCR extends RootEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "document_id", referencedColumnName = "id") + private Document document; + + @NotNull + @Column(name = "page_number") + private Integer pageNumber; + + @NotNull + @Column(name = "content", length = 255) + private String content; + + public OCR(Document document, Integer pageNumber, String content) { + this.document = document; + this.pageNumber = pageNumber; + this.content = content; + } +} diff --git a/src/main/java/notai/ocr/domain/OCRRepository.java b/src/main/java/notai/ocr/domain/OCRRepository.java new file mode 100644 index 0000000..144a12d --- /dev/null +++ b/src/main/java/notai/ocr/domain/OCRRepository.java @@ -0,0 +1,17 @@ +package notai.ocr.domain; + +import notai.common.exception.type.NotFoundException; +import notai.document.domain.Document; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OCRRepository extends JpaRepository { + default OCR getById(Long id) { + return findById(id).orElseThrow(() -> new NotFoundException("OCR 데이터를 찾을 수 없습니다.")); + } + + List findAllByDocumentId(Long documentId); + + void deleteAllByDocument(Document document); +} diff --git a/src/main/java/notai/document/presentation/PdfController.java b/src/main/java/notai/pdf/PdfController.java similarity index 92% rename from src/main/java/notai/document/presentation/PdfController.java rename to src/main/java/notai/pdf/PdfController.java index 0b70edf..9d58c78 100644 --- a/src/main/java/notai/document/presentation/PdfController.java +++ b/src/main/java/notai/pdf/PdfController.java @@ -1,7 +1,6 @@ -package notai.document.presentation; +package notai.pdf; import lombok.RequiredArgsConstructor; -import notai.document.application.PdfService; import org.springframework.core.io.FileSystemResource; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; diff --git a/src/main/java/notai/document/application/PdfService.java b/src/main/java/notai/pdf/PdfService.java similarity index 88% rename from src/main/java/notai/document/application/PdfService.java rename to src/main/java/notai/pdf/PdfService.java index 74f887a..a4a33d8 100644 --- a/src/main/java/notai/document/application/PdfService.java +++ b/src/main/java/notai/pdf/PdfService.java @@ -1,8 +1,9 @@ -package notai.document.application; +package notai.pdf; import lombok.RequiredArgsConstructor; import notai.common.exception.type.FileProcessException; import notai.common.exception.type.NotFoundException; +import notai.pdf.result.PdfSaveResult; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -19,7 +20,7 @@ public class PdfService { private static final String STORAGE_DIR = "src/main/resources/pdf/"; - public String savePdf(MultipartFile file) { + public PdfSaveResult savePdf(MultipartFile file) { try { Path directoryPath = Paths.get(STORAGE_DIR); if (!Files.exists(directoryPath)) { @@ -30,7 +31,7 @@ public String savePdf(MultipartFile file) { Path filePath = directoryPath.resolve(fileName); file.transferTo(filePath.toFile()); - return fileName; + return PdfSaveResult.of(fileName, filePath.toFile()); } catch (IOException exception) { throw new FileProcessException("자료를 저장하는 과정에서 에러가 발생했습니다."); } diff --git a/src/main/java/notai/pdf/result/PdfSaveResult.java b/src/main/java/notai/pdf/result/PdfSaveResult.java new file mode 100644 index 0000000..340d4af --- /dev/null +++ b/src/main/java/notai/pdf/result/PdfSaveResult.java @@ -0,0 +1,19 @@ +package notai.pdf.result; + +import java.io.File; + +public record PdfSaveResult( + String pdfName, + String pdfUrl, + File pdf +) { + public static PdfSaveResult of( + String pdfName, File pdf + ) { + return new PdfSaveResult(pdfName, convertPdfUrl(pdfName), pdf); + } + + private static String convertPdfUrl(String pdfName) { + return String.format("pdf/%s", pdfName); + } +} diff --git a/src/test/java/notai/document/application/DocumentServiceTest.java b/src/test/java/notai/document/application/DocumentServiceTest.java deleted file mode 100644 index 6b82bfd..0000000 --- a/src/test/java/notai/document/application/DocumentServiceTest.java +++ /dev/null @@ -1,13 +0,0 @@ -package notai.document.application; - -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class DocumentServiceTest { - - @InjectMocks - PdfService pdfService; - -} diff --git a/src/test/java/notai/document/application/PdfServiceTest.java b/src/test/java/notai/document/application/PdfServiceTest.java deleted file mode 100644 index 70f2397..0000000 --- a/src/test/java/notai/document/application/PdfServiceTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package notai.document.application; - -import net.sourceforge.tess4j.Tesseract; -import org.apache.pdfbox.Loader; -import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.rendering.PDFRenderer; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.core.io.ClassPathResource; -import org.springframework.mock.web.MockMultipartFile; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; - -@ExtendWith(MockitoExtension.class) -class PdfServiceTest { - - @InjectMocks - PdfService pdfService; - - static final String STORAGE_DIR = "src/main/resources/pdf/"; - - @Test - void savePdf_success_existsTestPdf() throws IOException { - //given - ClassPathResource existsPdf = new ClassPathResource("pdf/test.pdf"); - MockMultipartFile mockFile = new MockMultipartFile("file", - existsPdf.getFilename(), - "application/pdf", - Files.readAllBytes(existsPdf.getFile().toPath()) - ); - //when - String savedFileName = pdfService.savePdf(mockFile); - //then - Path savedFilePath = Paths.get(STORAGE_DIR, savedFileName); - Assertions.assertThat(Files.exists(savedFilePath)).isTrue(); - - System.setProperty("jna.library.path", "/usr/local/opt/tesseract/lib/"); - //window, mac -> brew install tesseract, tesseract-lang - Tesseract tesseract = new Tesseract(); - - tesseract.setDatapath("/usr/local/share/tessdata"); - tesseract.setLanguage("kor+eng"); - - try { - PDDocument pdDocument = Loader.loadPDF(savedFilePath.toFile()); - PDFRenderer pdfRenderer = new PDFRenderer(pdDocument); - - var image = pdfRenderer.renderImage(9); - var start = System.currentTimeMillis(); - var ocrResult = tesseract.doOCR(image); - System.out.println("result : " + ocrResult); - var end = System.currentTimeMillis(); - System.out.println(end - start); - pdDocument.close(); - } catch (Exception e) { - e.printStackTrace(); - } - - deleteFile(savedFilePath); - } - - void deleteFile(Path filePath) throws IOException { - if (Files.exists(filePath)) { - Files.delete(filePath); - } - } -} diff --git a/src/test/java/notai/ocr/application/OCRServiceTest.java b/src/test/java/notai/ocr/application/OCRServiceTest.java new file mode 100644 index 0000000..50c0b38 --- /dev/null +++ b/src/test/java/notai/ocr/application/OCRServiceTest.java @@ -0,0 +1,49 @@ +package notai.ocr.application; + +import notai.document.domain.Document; +import notai.ocr.domain.OCR; +import notai.ocr.domain.OCRRepository; +import notai.pdf.result.PdfSaveResult; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import static org.mockito.ArgumentMatchers.any; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import static org.mockito.Mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +@ExtendWith(MockitoExtension.class) +class OCRServiceTest { + + @InjectMocks + OCRService ocrService; + @Mock + OCRRepository ocrRepository; + + @Test + void savePdf_success_existsTestPdf() throws IOException { + //given + Document document = mock(Document.class); + OCR ocr = mock(OCR.class); + ClassPathResource existsPdf = new ClassPathResource("pdf/test.pdf"); + PdfSaveResult saveResult = PdfSaveResult.of("test.pdf", existsPdf.getFile()); + when(ocrRepository.save(any(OCR.class))).thenReturn(ocr); + //when + ocrService.saveOCR(document, saveResult.pdf()); + //then + verify(ocrRepository, times(43)).save(any(OCR.class)); + + deleteFile(saveResult.pdf().toPath()); + } + + void deleteFile(Path filePath) throws IOException { + if (Files.exists(filePath)) { + Files.delete(filePath); + } + } +} From 15d7a6d4b0eaad836e33adbbb8816757a8e00628 Mon Sep 17 00:00:00 2001 From: Hyun-Seo Jeong <90139789+hynseoj@users.noreply.github.com> Date: Fri, 11 Oct 2024 23:18:50 +0900 Subject: [PATCH 12/12] =?UTF-8?q?Refactor:=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=83=81=EC=88=98=ED=99=94=20(#2?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor: 에러 메시지 상수화 * Refactor: 코드 통합 후 에러 메시지 상수화 --- .../application/AnnotationQueryService.java | 4 +- .../domain/AnnotationRepository.java | 4 +- src/main/java/notai/auth/TokenService.java | 8 ++- .../java/notai/client/ai/AiClientConfig.java | 6 +- .../client/oauth/OauthClientComposite.java | 3 +- .../client/oauth/kakao/KakaoClientConfig.java | 3 +- .../converter/JsonAttributeConverter.java | 10 ++- .../java/notai/common/domain/vo/FilePath.java | 3 +- .../exception/ApplicationException.java | 4 +- .../notai/common/exception/ErrorMessages.java | 62 +++++++++++++++++++ .../exception/type/BadRequestException.java | 3 +- .../exception/type/ConflictException.java | 3 +- .../exception/type/ExternalApiException.java | 5 +- .../exception/type/FileProcessException.java | 3 +- .../exception/type/ForbiddenException.java | 3 +- .../type/InternalServerErrorException.java | 3 +- .../type/JsonConversionException.java | 3 +- .../exception/type/NotFoundException.java | 3 +- .../exception/type/UnAuthorizedException.java | 3 +- .../java/notai/document/domain/Document.java | 11 +++- .../document/domain/DocumentRepository.java | 4 +- src/main/java/notai/folder/domain/Folder.java | 11 +++- .../notai/folder/domain/FolderRepository.java | 4 +- .../llm/application/LLMQueryService.java | 17 +++-- .../java/notai/llm/domain/LLMRepository.java | 4 +- .../notai/member/domain/MemberRepository.java | 7 ++- .../notai/ocr/application/OCRService.java | 4 +- .../java/notai/ocr/domain/OCRRepository.java | 4 +- .../application/PageRecordingService.java | 4 +- src/main/java/notai/pdf/PdfService.java | 7 ++- .../problem/domain/ProblemRepository.java | 4 +- .../application/RecordingService.java | 7 ++- .../recording/domain/RecordingRepository.java | 4 +- .../summary/domain/SummaryRepository.java | 4 +- 34 files changed, 182 insertions(+), 50 deletions(-) create mode 100644 src/main/java/notai/common/exception/ErrorMessages.java diff --git a/src/main/java/notai/annotation/application/AnnotationQueryService.java b/src/main/java/notai/annotation/application/AnnotationQueryService.java index 9d3a879..54b7b79 100644 --- a/src/main/java/notai/annotation/application/AnnotationQueryService.java +++ b/src/main/java/notai/annotation/application/AnnotationQueryService.java @@ -12,6 +12,8 @@ import java.util.List; import java.util.stream.Collectors; +import static notai.common.exception.ErrorMessages.ANNOTATION_NOT_FOUND; + @Service @RequiredArgsConstructor public class AnnotationQueryService { @@ -25,7 +27,7 @@ public List getAnnotationsByDocumentAndPageNumbers(Long docu List annotations = annotationRepository.findByDocumentIdAndPageNumberIn(documentId, pageNumbers); if (annotations.isEmpty()) { - throw new NotFoundException("해당 문서에 해당 페이지 번호의 주석이 존재하지 않습니다."); + throw new NotFoundException(ANNOTATION_NOT_FOUND); } return annotations.stream() diff --git a/src/main/java/notai/annotation/domain/AnnotationRepository.java b/src/main/java/notai/annotation/domain/AnnotationRepository.java index 5ff2bfa..003a1fc 100644 --- a/src/main/java/notai/annotation/domain/AnnotationRepository.java +++ b/src/main/java/notai/annotation/domain/AnnotationRepository.java @@ -6,6 +6,8 @@ import java.util.List; import java.util.Optional; +import static notai.common.exception.ErrorMessages.ANNOTATION_NOT_FOUND; + public interface AnnotationRepository extends JpaRepository { List findByDocumentIdAndPageNumberIn(Long documentId, List pageNumbers); @@ -15,7 +17,7 @@ public interface AnnotationRepository extends JpaRepository { default Annotation getById(Long annotationId) { return findById(annotationId) - .orElseThrow(() -> new NotFoundException("주석을 찾을 수 없습니다. ID: " + annotationId)); + .orElseThrow(() -> new NotFoundException(ANNOTATION_NOT_FOUND)); } List findByDocumentIdAndPageNumber(Long documentId, Integer pageNumber); diff --git a/src/main/java/notai/auth/TokenService.java b/src/main/java/notai/auth/TokenService.java index 9204e5a..9b35d85 100644 --- a/src/main/java/notai/auth/TokenService.java +++ b/src/main/java/notai/auth/TokenService.java @@ -12,6 +12,8 @@ import javax.crypto.SecretKey; import java.util.Date; +import static notai.common.exception.ErrorMessages.*; + @Component public class TokenService { private static final String MEMBER_ID_CLAIM = "memberId"; @@ -57,9 +59,9 @@ public TokenPair refreshTokenPair(String refreshToken) { try { Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(refreshToken); } catch (ExpiredJwtException e) { - throw new UnAuthorizedException("만료된 Refresh Token입니다."); + throw new UnAuthorizedException(EXPIRED_REFRESH_TOKEN); } catch (Exception e) { - throw new UnAuthorizedException("유효하지 않은 Refresh Token입니다."); + throw new UnAuthorizedException(INVALID_REFRESH_TOKEN); } Member member = memberRepository.getByRefreshToken(refreshToken); @@ -72,7 +74,7 @@ public Long extractMemberId(String token) { Long.class ); } catch (Exception e) { - throw new UnAuthorizedException("유효하지 않은 토큰입니다."); + throw new UnAuthorizedException(INVALID_ACCESS_TOKEN); } } } diff --git a/src/main/java/notai/client/ai/AiClientConfig.java b/src/main/java/notai/client/ai/AiClientConfig.java index 88b17f9..5ab3db6 100644 --- a/src/main/java/notai/client/ai/AiClientConfig.java +++ b/src/main/java/notai/client/ai/AiClientConfig.java @@ -1,7 +1,6 @@ package notai.client.ai; import lombok.extern.slf4j.Slf4j; -import static notai.client.HttpInterfaceUtil.createHttpInterface; import notai.common.exception.type.ExternalApiException; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -9,6 +8,9 @@ import org.springframework.http.HttpStatusCode; import org.springframework.web.client.RestClient; +import static notai.client.HttpInterfaceUtil.createHttpInterface; +import static notai.common.exception.ErrorMessages.AI_SERVER_ERROR; + @Slf4j @Configuration public class AiClientConfig { @@ -25,7 +27,7 @@ public AiClient aiClient() { }).defaultStatusHandler(HttpStatusCode::isError, (request, response) -> { String responseBody = new String(response.getBody().readAllBytes()); log.error("Response Status: {}", response.getStatusCode()); - throw new ExternalApiException(responseBody, response.getStatusCode().value()); + throw new ExternalApiException(AI_SERVER_ERROR, response.getStatusCode().value()); }).build(); return createHttpInterface(restClient, AiClient.class); } diff --git a/src/main/java/notai/client/oauth/OauthClientComposite.java b/src/main/java/notai/client/oauth/OauthClientComposite.java index f65aca6..855b7c0 100644 --- a/src/main/java/notai/client/oauth/OauthClientComposite.java +++ b/src/main/java/notai/client/oauth/OauthClientComposite.java @@ -11,6 +11,7 @@ import static java.util.function.Function.identity; import static java.util.stream.Collectors.toMap; +import static notai.common.exception.ErrorMessages.INVALID_LOGIN_TYPE; @Component public class OauthClientComposite { @@ -27,6 +28,6 @@ public Member fetchMember(OauthProvider oauthProvider, String accessToken) { public OauthClient getOauthClient(OauthProvider oauthProvider) { return Optional.ofNullable(oauthClients.get(oauthProvider)) - .orElseThrow(() -> new BadRequestException("지원하지 않는 소셜 로그인 타입입니다.")); + .orElseThrow(() -> new BadRequestException(INVALID_LOGIN_TYPE)); } } diff --git a/src/main/java/notai/client/oauth/kakao/KakaoClientConfig.java b/src/main/java/notai/client/oauth/kakao/KakaoClientConfig.java index 3b79924..87e89d2 100644 --- a/src/main/java/notai/client/oauth/kakao/KakaoClientConfig.java +++ b/src/main/java/notai/client/oauth/kakao/KakaoClientConfig.java @@ -8,6 +8,7 @@ import org.springframework.web.client.RestClient; import static notai.client.HttpInterfaceUtil.createHttpInterface; +import static notai.common.exception.ErrorMessages.KAKAO_API_ERROR; @Slf4j @Configuration @@ -19,7 +20,7 @@ public KakaoClient kakaoClient() { (request, response) -> { String responseData = new String(response.getBody().readAllBytes()); log.error("카카오톡 API 오류 : {}", responseData); - throw new ExternalApiException(responseData, response.getStatusCode().value()); + throw new ExternalApiException(KAKAO_API_ERROR, response.getStatusCode().value()); } ).build(); return createHttpInterface(restClient, KakaoClient.class); diff --git a/src/main/java/notai/common/converter/JsonAttributeConverter.java b/src/main/java/notai/common/converter/JsonAttributeConverter.java index 8641a8b..00adb62 100644 --- a/src/main/java/notai/common/converter/JsonAttributeConverter.java +++ b/src/main/java/notai/common/converter/JsonAttributeConverter.java @@ -5,10 +5,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; +import lombok.extern.slf4j.Slf4j; import notai.common.exception.type.JsonConversionException; import java.io.IOException; +import static notai.common.exception.ErrorMessages.JSON_CONVERSION_ERROR; + +@Slf4j @Converter public class JsonAttributeConverter implements AttributeConverter { @@ -24,7 +28,8 @@ public String convertToDatabaseColumn(T attribute) { try { return objectMapper.writeValueAsString(attribute); } catch (JsonProcessingException e) { - throw new JsonConversionException("객체를 JSON 문자열로 변환하는 중 오류가 발생했습니다."); + log.error("객체를 JSON 문자열로 변환하는 중 오류가 발생했습니다."); + throw new JsonConversionException(JSON_CONVERSION_ERROR); } } @@ -33,7 +38,8 @@ public T convertToEntityAttribute(String dbData) { try { return objectMapper.readValue(dbData, typeReference); } catch (IOException e) { - throw new JsonConversionException("JSON 문자열을 객체로 변환하는 중 오류가 발생했습니다."); + log.error("JSON 문자열을 객체로 변환하는 중 오류가 발생했습니다."); + throw new JsonConversionException(JSON_CONVERSION_ERROR); } } } diff --git a/src/main/java/notai/common/domain/vo/FilePath.java b/src/main/java/notai/common/domain/vo/FilePath.java index d9c04af..78c7abd 100644 --- a/src/main/java/notai/common/domain/vo/FilePath.java +++ b/src/main/java/notai/common/domain/vo/FilePath.java @@ -7,6 +7,7 @@ import notai.common.exception.type.BadRequestException; import static lombok.AccessLevel.PROTECTED; +import static notai.common.exception.ErrorMessages.INVALID_FILE_TYPE; @Embeddable @Getter @@ -24,7 +25,7 @@ public static FilePath from(String filePath) { // 추후 확장자 추가 if (!filePath.matches( "[a-zA-Z0-9ㄱ-ㅎㅏ-ㅣ가-힣()\\[\\]+\\-&/_\\s]+(/[a-zA-Z0-9ㄱ-ㅎㅏ-ㅣ가-힣()\\[\\]+\\-&/_\\s]+)*/?[a-zA-Z0-9ㄱ-ㅎㅏ-ㅣ가-힣()\\[\\]+\\-&/_\\s]+\\.mp3")) { - throw new BadRequestException("지원하지 않는 파일 형식입니다."); + throw new BadRequestException(INVALID_FILE_TYPE); } return new FilePath(filePath); } diff --git a/src/main/java/notai/common/exception/ApplicationException.java b/src/main/java/notai/common/exception/ApplicationException.java index 67aa9ca..c6bc1b3 100644 --- a/src/main/java/notai/common/exception/ApplicationException.java +++ b/src/main/java/notai/common/exception/ApplicationException.java @@ -7,8 +7,8 @@ public class ApplicationException extends RuntimeException { private final int code; - public ApplicationException(String message, int code) { - super(message); + public ApplicationException(ErrorMessages message, int code) { + super(message.getMessage()); this.code = code; } } diff --git a/src/main/java/notai/common/exception/ErrorMessages.java b/src/main/java/notai/common/exception/ErrorMessages.java new file mode 100644 index 0000000..32b904c --- /dev/null +++ b/src/main/java/notai/common/exception/ErrorMessages.java @@ -0,0 +1,62 @@ +package notai.common.exception; + +import lombok.Getter; + +@Getter +public enum ErrorMessages { + + // annotation + ANNOTATION_NOT_FOUND("주석을 찾을 수 없습니다."), + + // document + DOCUMENT_NOT_FOUND("자료를 찾을 수 없습니다."), + + // ocr + OCR_RESULT_NOT_FOUND("OCR 데이터를 찾을 수 없습니다."), + OCR_TASK_ERROR("PDF 파일을 통해 OCR 작업을 수행하는데 실패했습니다."), + + // folder + FOLDER_NOT_FOUND("폴더를 찾을 수 없습니다."), + + // llm task + LLM_TASK_LOG_NOT_FOUND("AI 작업 기록을 찾을 수 없습니다."), + LLM_TASK_RESULT_ERROR("AI 요약 및 문제 생성 중에 문제가 발생했습니다."), + + // problem + PROBLEM_NOT_FOUND("문제 정보를 찾을 수 없습니다."), + + // summary + SUMMARY_NOT_FOUND("요약 정보를 찾을 수 없습니다."), + + // member + MEMBER_NOT_FOUND("회원 정보를 찾을 수 없습니다."), + + // recording + RECORDING_NOT_FOUND("녹음 파일을 찾을 수 없습니다."), + + // external api call + KAKAO_API_ERROR("카카오 API 호출에 예외가 발생했습니다."), + AI_SERVER_ERROR("AI 서버 API 호출에 예외가 발생했습니다."), + + // auth + INVALID_ACCESS_TOKEN("유효하지 않은 토큰입니다."), + INVALID_REFRESH_TOKEN("유요하지 않은 Refresh Token입니다."), + EXPIRED_REFRESH_TOKEN("만료된 Refresh Token입니다."), + INVALID_LOGIN_TYPE("지원하지 않는 소셜 로그인 타입입니다."), + + // json conversion + JSON_CONVERSION_ERROR("JSON-객체 변환 중에 오류가 발생했습니다."), + + // etc + INVALID_FILE_TYPE("지원하지 않는 파일 형식입니다."), + FILE_NOT_FOUND("존재하지 않는 파일입니다."), + FILE_SAVE_ERROR("파일을 저장하는 과정에서 오류가 발생했습니다."), + INVALID_AUDIO_ENCODING("오디오 파일이 잘못되었습니다.") + ; + + private final String message; + + ErrorMessages(String message) { + this.message = message; + } +} diff --git a/src/main/java/notai/common/exception/type/BadRequestException.java b/src/main/java/notai/common/exception/type/BadRequestException.java index 80c9ba2..55e0cbc 100644 --- a/src/main/java/notai/common/exception/type/BadRequestException.java +++ b/src/main/java/notai/common/exception/type/BadRequestException.java @@ -2,10 +2,11 @@ import notai.common.exception.ApplicationException; +import notai.common.exception.ErrorMessages; public class BadRequestException extends ApplicationException { - public BadRequestException(String message) { + public BadRequestException(ErrorMessages message) { super(message, 400); } } diff --git a/src/main/java/notai/common/exception/type/ConflictException.java b/src/main/java/notai/common/exception/type/ConflictException.java index 9d99f18..c89f09f 100644 --- a/src/main/java/notai/common/exception/type/ConflictException.java +++ b/src/main/java/notai/common/exception/type/ConflictException.java @@ -2,10 +2,11 @@ import notai.common.exception.ApplicationException; +import notai.common.exception.ErrorMessages; public class ConflictException extends ApplicationException { - public ConflictException(String message) { + public ConflictException(ErrorMessages message) { super(message, 409); } } diff --git a/src/main/java/notai/common/exception/type/ExternalApiException.java b/src/main/java/notai/common/exception/type/ExternalApiException.java index 7783837..ad13baf 100644 --- a/src/main/java/notai/common/exception/type/ExternalApiException.java +++ b/src/main/java/notai/common/exception/type/ExternalApiException.java @@ -2,10 +2,11 @@ import notai.common.exception.ApplicationException; +import notai.common.exception.ErrorMessages; public class ExternalApiException extends ApplicationException { - public ExternalApiException(String message, int code) { - super("외부 API 호출 시 예외 발생: " + message, code); + public ExternalApiException(ErrorMessages message, int code) { + super(message, code); } } diff --git a/src/main/java/notai/common/exception/type/FileProcessException.java b/src/main/java/notai/common/exception/type/FileProcessException.java index 640ff7e..4a43313 100644 --- a/src/main/java/notai/common/exception/type/FileProcessException.java +++ b/src/main/java/notai/common/exception/type/FileProcessException.java @@ -2,10 +2,11 @@ import notai.common.exception.ApplicationException; +import notai.common.exception.ErrorMessages; public class FileProcessException extends ApplicationException { - public FileProcessException(String message) { + public FileProcessException(ErrorMessages message) { super(message, 500); } } diff --git a/src/main/java/notai/common/exception/type/ForbiddenException.java b/src/main/java/notai/common/exception/type/ForbiddenException.java index f8770c8..4706aa1 100644 --- a/src/main/java/notai/common/exception/type/ForbiddenException.java +++ b/src/main/java/notai/common/exception/type/ForbiddenException.java @@ -2,10 +2,11 @@ import notai.common.exception.ApplicationException; +import notai.common.exception.ErrorMessages; public class ForbiddenException extends ApplicationException { - public ForbiddenException(String message) { + public ForbiddenException(ErrorMessages message) { super(message, 403); } } diff --git a/src/main/java/notai/common/exception/type/InternalServerErrorException.java b/src/main/java/notai/common/exception/type/InternalServerErrorException.java index af0c3a4..592aae3 100644 --- a/src/main/java/notai/common/exception/type/InternalServerErrorException.java +++ b/src/main/java/notai/common/exception/type/InternalServerErrorException.java @@ -2,10 +2,11 @@ import notai.common.exception.ApplicationException; +import notai.common.exception.ErrorMessages; public class InternalServerErrorException extends ApplicationException { - public InternalServerErrorException(String message) { + public InternalServerErrorException(ErrorMessages message) { super(message, 500); } } diff --git a/src/main/java/notai/common/exception/type/JsonConversionException.java b/src/main/java/notai/common/exception/type/JsonConversionException.java index 962e327..7baae6b 100644 --- a/src/main/java/notai/common/exception/type/JsonConversionException.java +++ b/src/main/java/notai/common/exception/type/JsonConversionException.java @@ -1,10 +1,11 @@ package notai.common.exception.type; import notai.common.exception.ApplicationException; +import notai.common.exception.ErrorMessages; public class JsonConversionException extends ApplicationException { - public JsonConversionException(String message) { + public JsonConversionException(ErrorMessages message) { super(message, 500); } } diff --git a/src/main/java/notai/common/exception/type/NotFoundException.java b/src/main/java/notai/common/exception/type/NotFoundException.java index 20b23f1..5c89e8d 100644 --- a/src/main/java/notai/common/exception/type/NotFoundException.java +++ b/src/main/java/notai/common/exception/type/NotFoundException.java @@ -2,10 +2,11 @@ import notai.common.exception.ApplicationException; +import notai.common.exception.ErrorMessages; public class NotFoundException extends ApplicationException { - public NotFoundException(String message) { + public NotFoundException(ErrorMessages message) { super(message, 404); } } diff --git a/src/main/java/notai/common/exception/type/UnAuthorizedException.java b/src/main/java/notai/common/exception/type/UnAuthorizedException.java index af03c78..a26f38e 100644 --- a/src/main/java/notai/common/exception/type/UnAuthorizedException.java +++ b/src/main/java/notai/common/exception/type/UnAuthorizedException.java @@ -2,10 +2,11 @@ import notai.common.exception.ApplicationException; +import notai.common.exception.ErrorMessages; public class UnAuthorizedException extends ApplicationException { - public UnAuthorizedException(String message) { + public UnAuthorizedException(ErrorMessages message) { super(message, 401); } } diff --git a/src/main/java/notai/document/domain/Document.java b/src/main/java/notai/document/domain/Document.java index f2856f0..ff179a3 100644 --- a/src/main/java/notai/document/domain/Document.java +++ b/src/main/java/notai/document/domain/Document.java @@ -1,15 +1,19 @@ package notai.document.domain; import jakarta.persistence.*; -import static jakarta.persistence.GenerationType.IDENTITY; import jakarta.validation.constraints.NotNull; -import static lombok.AccessLevel.PROTECTED; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; import notai.common.domain.RootEntity; import notai.common.exception.type.NotFoundException; import notai.folder.domain.Folder; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; +import static notai.common.exception.ErrorMessages.DOCUMENT_NOT_FOUND; + +@Slf4j @Entity @Table(name = "document") @Getter @@ -45,7 +49,8 @@ public Document(String name, String url) { public void validateDocument(Long folderId) { if (!this.folder.getId().equals(folderId)) { - throw new NotFoundException("해당 폴더 내에 존재하지 않는 자료입니다."); + log.info("요청 폴더와 실제 문서를 소유한 폴더가 다릅니다."); + throw new NotFoundException(DOCUMENT_NOT_FOUND); } } diff --git a/src/main/java/notai/document/domain/DocumentRepository.java b/src/main/java/notai/document/domain/DocumentRepository.java index 803dd1b..a18272c 100644 --- a/src/main/java/notai/document/domain/DocumentRepository.java +++ b/src/main/java/notai/document/domain/DocumentRepository.java @@ -7,9 +7,11 @@ import java.util.List; +import static notai.common.exception.ErrorMessages.DOCUMENT_NOT_FOUND; + public interface DocumentRepository extends JpaRepository, DocumentQueryRepository { default Document getById(Long id) { - return findById(id).orElseThrow(() -> new NotFoundException("자료를 찾을 수 없습니다.")); + return findById(id).orElseThrow(() -> new NotFoundException(DOCUMENT_NOT_FOUND)); } List findAllByFolderId(Long folderId); diff --git a/src/main/java/notai/folder/domain/Folder.java b/src/main/java/notai/folder/domain/Folder.java index 84458bb..005ea4b 100644 --- a/src/main/java/notai/folder/domain/Folder.java +++ b/src/main/java/notai/folder/domain/Folder.java @@ -1,15 +1,19 @@ package notai.folder.domain; import jakarta.persistence.*; -import static jakarta.persistence.GenerationType.IDENTITY; import jakarta.validation.constraints.NotNull; -import static lombok.AccessLevel.PROTECTED; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; import notai.common.domain.RootEntity; import notai.common.exception.type.NotFoundException; import notai.member.domain.Member; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; +import static notai.common.exception.ErrorMessages.FOLDER_NOT_FOUND; + +@Slf4j @Entity @Table(name = "folder") @Getter @@ -54,7 +58,8 @@ public void moveNewParentFolder(Folder parentFolder) { public void validateOwner(Long memberId) { if (!this.member.getId().equals(memberId)) { - throw new NotFoundException("해당 이용자가 보유한 폴더 중 이 폴더가 존재하지 않습니다."); + log.info("폴더 소유자가 요청 사용자와 다릅니다."); + throw new NotFoundException(FOLDER_NOT_FOUND); } } } diff --git a/src/main/java/notai/folder/domain/FolderRepository.java b/src/main/java/notai/folder/domain/FolderRepository.java index d1fbd3c..5b64c16 100644 --- a/src/main/java/notai/folder/domain/FolderRepository.java +++ b/src/main/java/notai/folder/domain/FolderRepository.java @@ -5,9 +5,11 @@ import java.util.List; +import static notai.common.exception.ErrorMessages.FOLDER_NOT_FOUND; + public interface FolderRepository extends JpaRepository { default Folder getById(Long id) { - return findById(id).orElseThrow(() -> new NotFoundException("폴더 정보를 찾을 수 없습니다.")); + return findById(id).orElseThrow(() -> new NotFoundException(FOLDER_NOT_FOUND)); } List findAllByMemberIdAndParentFolderIsNull(Long memberId); diff --git a/src/main/java/notai/llm/application/LLMQueryService.java b/src/main/java/notai/llm/application/LLMQueryService.java index 74924e7..0a9b6cd 100644 --- a/src/main/java/notai/llm/application/LLMQueryService.java +++ b/src/main/java/notai/llm/application/LLMQueryService.java @@ -1,6 +1,7 @@ package notai.llm.application; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import notai.common.exception.type.InternalServerErrorException; import notai.common.exception.type.NotFoundException; import notai.document.domain.DocumentRepository; @@ -19,9 +20,11 @@ import java.util.Collections; import java.util.List; +import static notai.common.exception.ErrorMessages.*; import static notai.llm.domain.TaskStatus.COMPLETED; import static notai.llm.domain.TaskStatus.IN_PROGRESS; +@Slf4j @Service @RequiredArgsConstructor public class LLMQueryService { @@ -64,7 +67,7 @@ public LLMResultsResult findTaskResult(Long documentId) { private void checkDocumentExists(Long documentId) { if (!documentRepository.existsById(documentId)) { - throw new NotFoundException("해당 강의자료를 찾을 수 없습니다."); + throw new NotFoundException(DOCUMENT_NOT_FOUND); } } @@ -72,14 +75,15 @@ private static void checkSummaryAndProblemCountsEqual( List summaryResults, List problemResults ) { if (summaryResults.size() != problemResults.size()) { - throw new InternalServerErrorException("AI 요약 및 문제 생성 중에 문제가 발생했습니다."); // 요약 개수와 문제 개수가 불일치 + log.error("요약 개수와 문제 개수가 일치하지 않습니다. 요약: {} 개, 문제: {} 개", summaryResults.size(), problemResults.size()); + throw new InternalServerErrorException(LLM_TASK_RESULT_ERROR); } } private List getSummaryIds(Long documentId) { List summaryIds = summaryQueryRepository.getSummaryIdsByDocumentId(documentId); if (summaryIds.isEmpty()) { - throw new NotFoundException("AI 기능을 요청한 기록이 없습니다."); + throw new NotFoundException(LLM_TASK_LOG_NOT_FOUND); } return summaryIds; } @@ -92,7 +96,7 @@ private List getSummaryPageContentResults(Long documen List summaryResults = summaryQueryRepository.getPageNumbersAndContentByDocumentId( documentId); if (summaryResults.isEmpty()) { - throw new NotFoundException("AI 기능을 요청한 기록이 없습니다."); + throw new NotFoundException(LLM_TASK_LOG_NOT_FOUND); } return summaryResults; } @@ -106,6 +110,9 @@ private String findProblemContentByPageNumber(List res .filter(result -> result.pageNumber() == pageNumber) .findFirst() .map(ProblemPageContentResult::content) - .orElseThrow(() -> new InternalServerErrorException("AI 요약 및 문제 생성 중에 문제가 발생했습니다.")); // 요약 페이지와 문제 페이지가 불일치 + .orElseThrow(() -> { + log.error("{} 페이지에 대한 문제 생성 결과가 없습니다.", pageNumber); + return new InternalServerErrorException(LLM_TASK_RESULT_ERROR); + }); } } diff --git a/src/main/java/notai/llm/domain/LLMRepository.java b/src/main/java/notai/llm/domain/LLMRepository.java index c9bfa6c..9f3cde1 100644 --- a/src/main/java/notai/llm/domain/LLMRepository.java +++ b/src/main/java/notai/llm/domain/LLMRepository.java @@ -5,8 +5,10 @@ import java.util.UUID; +import static notai.common.exception.ErrorMessages.LLM_TASK_LOG_NOT_FOUND; + public interface LLMRepository extends JpaRepository { default LLM getById(UUID id) { - return findById(id).orElseThrow(() -> new NotFoundException("해당 작업 기록을 찾을 수 없습니다.")); + return findById(id).orElseThrow(() -> new NotFoundException(LLM_TASK_LOG_NOT_FOUND)); } } diff --git a/src/main/java/notai/member/domain/MemberRepository.java b/src/main/java/notai/member/domain/MemberRepository.java index e3a8ee6..eb19d01 100644 --- a/src/main/java/notai/member/domain/MemberRepository.java +++ b/src/main/java/notai/member/domain/MemberRepository.java @@ -6,16 +6,19 @@ import java.util.Optional; +import static notai.common.exception.ErrorMessages.INVALID_REFRESH_TOKEN; +import static notai.common.exception.ErrorMessages.MEMBER_NOT_FOUND; + public interface MemberRepository extends JpaRepository { Optional findByOauthId(OauthId oauthId); default Member getById(Long id) { - return findById(id).orElseThrow(() -> new NotFoundException("회원 정보를 찾을 수 없습니다.")); + return findById(id).orElseThrow(() -> new NotFoundException(MEMBER_NOT_FOUND)); } Optional findByRefreshToken(String refreshToken); default Member getByRefreshToken(String refreshToken) { - return findByRefreshToken(refreshToken).orElseThrow(() -> new UnAuthorizedException("유효하지 않은 Refresh Token입니다.")); + return findByRefreshToken(refreshToken).orElseThrow(() -> new UnAuthorizedException(INVALID_REFRESH_TOKEN)); } } diff --git a/src/main/java/notai/ocr/application/OCRService.java b/src/main/java/notai/ocr/application/OCRService.java index ee63f5e..42cc619 100644 --- a/src/main/java/notai/ocr/application/OCRService.java +++ b/src/main/java/notai/ocr/application/OCRService.java @@ -15,6 +15,8 @@ import java.awt.image.BufferedImage; import java.io.File; +import static notai.common.exception.ErrorMessages.OCR_TASK_ERROR; + @Service @RequiredArgsConstructor public class OCRService { @@ -44,7 +46,7 @@ public void saveOCR( pdDocument.close(); } catch (Exception e) { - throw new FileProcessException("PDF 파일을 통해 OCR 작업을 수행하는데 실패했습니다."); + throw new FileProcessException(OCR_TASK_ERROR); } } } diff --git a/src/main/java/notai/ocr/domain/OCRRepository.java b/src/main/java/notai/ocr/domain/OCRRepository.java index 144a12d..18da4e8 100644 --- a/src/main/java/notai/ocr/domain/OCRRepository.java +++ b/src/main/java/notai/ocr/domain/OCRRepository.java @@ -6,9 +6,11 @@ import java.util.List; +import static notai.common.exception.ErrorMessages.OCR_RESULT_NOT_FOUND; + public interface OCRRepository extends JpaRepository { default OCR getById(Long id) { - return findById(id).orElseThrow(() -> new NotFoundException("OCR 데이터를 찾을 수 없습니다.")); + return findById(id).orElseThrow(() -> new NotFoundException(OCR_RESULT_NOT_FOUND)); } List findAllByDocumentId(Long documentId); diff --git a/src/main/java/notai/pageRecording/application/PageRecordingService.java b/src/main/java/notai/pageRecording/application/PageRecordingService.java index 4baae7a..c8b4a44 100644 --- a/src/main/java/notai/pageRecording/application/PageRecordingService.java +++ b/src/main/java/notai/pageRecording/application/PageRecordingService.java @@ -10,6 +10,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import static notai.common.exception.ErrorMessages.RECORDING_NOT_FOUND; + @Service @Transactional @RequiredArgsConstructor @@ -35,7 +37,7 @@ public void savePageRecording(PageRecordingSaveCommand command) { private static void checkDocumentOwnershipOfRecording(PageRecordingSaveCommand command, Recording foundRecording) { if (!foundRecording.isRecordingOwnedByDocument(command.documentId())) { - throw new NotFoundException("해당 녹음 파일을 찾을 수 없습니다."); + throw new NotFoundException(RECORDING_NOT_FOUND); } } } diff --git a/src/main/java/notai/pdf/PdfService.java b/src/main/java/notai/pdf/PdfService.java index a4a33d8..c07a2e0 100644 --- a/src/main/java/notai/pdf/PdfService.java +++ b/src/main/java/notai/pdf/PdfService.java @@ -14,6 +14,9 @@ import java.nio.file.Paths; import java.util.UUID; +import static notai.common.exception.ErrorMessages.FILE_NOT_FOUND; +import static notai.common.exception.ErrorMessages.FILE_SAVE_ERROR; + @Service @RequiredArgsConstructor public class PdfService { @@ -33,7 +36,7 @@ public PdfSaveResult savePdf(MultipartFile file) { return PdfSaveResult.of(fileName, filePath.toFile()); } catch (IOException exception) { - throw new FileProcessException("자료를 저장하는 과정에서 에러가 발생했습니다."); + throw new FileProcessException(FILE_SAVE_ERROR); } } @@ -41,7 +44,7 @@ public File getPdf(String fileName) { Path filePath = Paths.get(STORAGE_DIR, fileName); if (!Files.exists(filePath)) { - throw new NotFoundException("존재하지 않는 파일입니다."); + throw new NotFoundException(FILE_NOT_FOUND); } return filePath.toFile(); } diff --git a/src/main/java/notai/problem/domain/ProblemRepository.java b/src/main/java/notai/problem/domain/ProblemRepository.java index d5f558d..dcfc221 100644 --- a/src/main/java/notai/problem/domain/ProblemRepository.java +++ b/src/main/java/notai/problem/domain/ProblemRepository.java @@ -3,8 +3,10 @@ import notai.common.exception.type.NotFoundException; import org.springframework.data.jpa.repository.JpaRepository; +import static notai.common.exception.ErrorMessages.PROBLEM_NOT_FOUND; + public interface ProblemRepository extends JpaRepository { default Problem getById(Long id) { - return findById(id).orElseThrow(() -> new NotFoundException("해당 문제 정보를 찾을 수 없습니다.")); + return findById(id).orElseThrow(() -> new NotFoundException(PROBLEM_NOT_FOUND)); } } diff --git a/src/main/java/notai/recording/application/RecordingService.java b/src/main/java/notai/recording/application/RecordingService.java index 3098466..7f65bfb 100644 --- a/src/main/java/notai/recording/application/RecordingService.java +++ b/src/main/java/notai/recording/application/RecordingService.java @@ -20,6 +20,9 @@ import java.nio.file.Path; import java.nio.file.Paths; +import static notai.common.exception.ErrorMessages.FILE_SAVE_ERROR; +import static notai.common.exception.ErrorMessages.INVALID_AUDIO_ENCODING; + @Service @Transactional @RequiredArgsConstructor @@ -52,9 +55,9 @@ public RecordingSaveResult saveRecording(RecordingSaveCommand command) { return RecordingSaveResult.of(savedRecording.getId(), foundDocument.getId(), savedRecording.getCreatedAt()); } catch (IllegalArgumentException e) { - throw new BadRequestException("오디오 파일이 잘못되었습니다."); + throw new BadRequestException(INVALID_AUDIO_ENCODING); } catch (IOException e) { - throw new InternalServerErrorException("녹음 파일 저장 중 오류가 발생했습니다."); // TODO: 재시도 로직 추가? + throw new InternalServerErrorException(FILE_SAVE_ERROR); // TODO: 재시도 로직 추가? } } } diff --git a/src/main/java/notai/recording/domain/RecordingRepository.java b/src/main/java/notai/recording/domain/RecordingRepository.java index 1c667aa..8eeaa31 100644 --- a/src/main/java/notai/recording/domain/RecordingRepository.java +++ b/src/main/java/notai/recording/domain/RecordingRepository.java @@ -3,8 +3,10 @@ import notai.common.exception.type.NotFoundException; import org.springframework.data.jpa.repository.JpaRepository; +import static notai.common.exception.ErrorMessages.RECORDING_NOT_FOUND; + public interface RecordingRepository extends JpaRepository { default Recording getById(Long id) { - return findById(id).orElseThrow(() -> new NotFoundException("해당 녹음 정보를 찾을 수 없습니다.")); + return findById(id).orElseThrow(() -> new NotFoundException(RECORDING_NOT_FOUND)); } } diff --git a/src/main/java/notai/summary/domain/SummaryRepository.java b/src/main/java/notai/summary/domain/SummaryRepository.java index 45d6b5d..87f661f 100644 --- a/src/main/java/notai/summary/domain/SummaryRepository.java +++ b/src/main/java/notai/summary/domain/SummaryRepository.java @@ -3,8 +3,10 @@ import notai.common.exception.type.NotFoundException; import org.springframework.data.jpa.repository.JpaRepository; +import static notai.common.exception.ErrorMessages.SUMMARY_NOT_FOUND; + public interface SummaryRepository extends JpaRepository { default Summary getById(Long id) { - return findById(id).orElseThrow(() -> new NotFoundException("해당 요약 정보를 찾을 수 없습니다.")); + return findById(id).orElseThrow(() -> new NotFoundException(SUMMARY_NOT_FOUND)); } }