diff --git a/.github/ISSUE_TEMPLATE/corecord-issue-template.md b/.github/ISSUE_TEMPLATE/corecord-issue-template.md new file mode 100644 index 0000000..5390319 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/corecord-issue-template.md @@ -0,0 +1,17 @@ +--- +name: CORECORD issue template +about: CORECORD Issue Template +title: '' +labels: '' +assignees: '' + +--- + +### ✨ 이슈 내용 +> + +### 💡 작업 내용 +- [ ] +- [ ] + +### 📌 참고 사항 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e827dab --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,9 @@ +### #️⃣ 관련 이슈 +- closed # + +### 💡 작업내용 + +### 📸 스크린샷(선택) + +### 📝 기타 +(참고사항, 리뷰어에게 전하고 싶은 말 등을 넣어주세요) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml new file mode 100644 index 0000000..a891254 --- /dev/null +++ b/.github/workflows/cicd.yml @@ -0,0 +1,88 @@ +name: CD + +on: + push: + branches: [ "develop" ] + +jobs: + deploy-ci: + runs-on: ubuntu-22.04 + env: + working-directory: notify + + steps: + - uses: actions/checkout@v3 + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: '21' + + + - name: make application-secret.yml + run: | + touch ./src/main/resources/application-secret.yml + echo "${{ secrets.APPLICATION_SECRET }}" > ./src/main/resources/application-secret.yml + shell: bash + + - name: make chat-prompt.txt + run: | + touch ./src/main/resources/chat-prompt.txt + echo "${{ secrets.CHAT_PROMPT }}" > ./src/main/resources/chat-prompt.txt + shell: bash + + - name: make chat-summary-prompt.txt + run: | + cat < ./src/main/resources/chat-summary-prompt.txt + ${{ secrets.CHAT_SUMMARY_PROMPT }} + EOF + shell: bash + + - name: make memo-summary-prompt.txt + run: | + touch ./src/main/resources/memo-summary-prompt.txt + echo "${{ secrets.MEMO_SUMMARY_PROMPT }}" > ./src/main/resources/memo-summary-prompt.txt + shell: bash + + - name: make ability-analysis-prompt.txt + run: | + cat < ./src/main/resources/ability-analysis-prompt.txt + ${{ secrets.ABILITY_ANALYSIS_PROMPT }} + EOF + shell: bash + + + - name: 빌드 + run: | + chmod +x gradlew + ./gradlew build -x test + shell: bash + + - name: docker build 가능하도록 환경 설정 + uses: docker/setup-buildx-action@v2.9.1 + + - name: docker hub에로그인 + uses: docker/login-action@v2.2.0 + with: + username: ${{ secrets.DOCKERHUB_LOGIN_USERNAME }} + password: ${{ secrets.DOCKERHUB_LOGIN_ACCESSTOKEN }} + + - name: docker image 빌드 및 푸시 + run: | + docker build --platform linux/amd64 -t corecord/notify . + docker push corecord/notify + + deploy-cd: + needs: deploy-ci + runs-on: ubuntu-22.04 + steps: + - name: 도커 컨테이너 실행 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.RELEASE_HOST }} + username: ${{ secrets.RELEASE_USERNAME }} + key: ${{ secrets.RELEASE_KEY }} + script: | + sudo chmod +x /home/ubuntu/deploy.sh + sudo /home/ubuntu/deploy.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..04ce42e --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +.DS_Store +src/main/resources/application-secret.yml +src/main/resources/chat-prompt.txt +src/main/resources/chat-summary-prompt.txt +src/main/resources/ability-analysis-prompt.txt +src/main/resources/memo-summary-prompt.txt \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fd68700 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM amd64/amazoncorretto:21 + +WORKDIR /app + +COPY ./build/libs/dev-0.0.1-SNAPSHOT.jar /app/notify.jar + +CMD ["java", "-Duser.timezone=Asia/Seoul", "-jar", "-Dspring.profiles.active=dev", "/app/notify.jar"] diff --git a/README.md b/README.md index a11cae0..f83d46a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,99 @@ -# CORECORD_BE -대학생IT경영학회 큐시즘 30th CORECORD Backend Repository +# 🎯 MOAMOA_BE + +> 대학생IT경영학회 큐시즘 30th 밋업 프로젝트 G팀 MOAMOA Backend Repository
+> 2024.09.28 ~ 2024.11.28 + +
+ +## 👥 Member +| 김다은 | 오세연 | +| :------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------: | +| 김다은 | 오세연 | +| [@daeun084](https://github.com/daeun084) | [@oosedus](https://github.com/oosedus) | +| 숭실대학교 컴퓨터학부 | 서울과학기술대학교 ITM전공 | + +

+ + +## 📐 Convention + +#### Convention Type +| convention type | description | +| --- | --- | +| `feat` | 새로운 기능 구현 | +| `add` | 파일 및 코드 추가 | +| `chore` | 부수적인 코드 수정 및 기타 변경사항 | +| `docs` | 문서 추가 및 수정, 삭제 | +| `fix` | 버그 수정 | +| `rename` | 파일 및 폴더 이름 변경 | +| `test` | 테스트 코드 추가 및 수정, 삭제 | +| `refactor` | 코드 리팩토링 | +| `!hotfix` | develop 브랜치에 급하게 커밋해야 하는 경우 | + +#### Commit +- **`ConventionType: 구현한 내용`** + +#### Issue +- Issue Title : **`ConventionType: 작업할 내용`** +- 모든 작업은 `Issue`를 만든 후, 해당 이슈 번호에 대한 branch를 통해 수행 +- 수행할 작업에 대한 설명과 할 일을 작성 + +#### Pull Request +- Pull Request Title : **`[ContentionType/#이슈번호] 작업한 내용`** +- 수행한 작업에 대한 설명을 작성하고 관련 스크린샷을 첨부 +- Reviewer, Assigner, Label, Project, Milestone, 관련 이슈를 태그 +- 작업 중 참고한 자료 혹은 reviewer에게 전할 내용이 있다면 하단에 작성 + +#### Branch +- Branch Name : **`컨벤션명/#이슈번호`** +- `Pull Request`를 통해 develop branch에 merge 후, 해당 branch 제거 + +

+ +## 🛠️ Stack +**Language & Framework** + + + + +**Documentation** + + +**Database & ORM** + + + +**Build Tool** + + +**Cloud & Hosting** + + + +**Containerization & CI/CD** + + + +**Network & Security** + + + + +

+ + +## 🏛️ Architecture +![MOAMOA_ARCHITECTURE](https://github.com/user-attachments/assets/d9aaee68-0793-482c-b57a-2f2ab4d56756) + + +

+ +## 📊 ERD +![MOAMOA_ERD](https://github.com/user-attachments/assets/fdd8622b-8418-45e2-966a-9757fc5e8b7c) + + + + + + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..6d89895 --- /dev/null +++ b/build.gradle @@ -0,0 +1,69 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.4' + id 'io.spring.dependency-management' version '1.1.6' +} + +group = 'corecord' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() + maven { url 'https://repo.spring.io/milestone' } +} + +ext { + set('springAiVersion', "1.0.0-M2") +} + +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' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // OAuth 2.0 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + //JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + // 시큐리티 + implementation 'org.springframework.boot:spring-boot-starter-security' + //Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // WebClient + implementation 'org.springframework.boot:spring-boot-starter-webflux' + // Open Ai + implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter' +} + +dependencyManagement { + imports { + mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}" + } +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df97d72 --- /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.2-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..9d21a21 --- /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..9cce8d6 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'dev' diff --git a/src/main/java/corecord/dev/DevApplication.java b/src/main/java/corecord/dev/DevApplication.java new file mode 100644 index 0000000..4d211c3 --- /dev/null +++ b/src/main/java/corecord/dev/DevApplication.java @@ -0,0 +1,15 @@ +package corecord.dev; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@SpringBootApplication +@EnableJpaAuditing +public class DevApplication { + + public static void main(String[] args) { + SpringApplication.run(DevApplication.class, args); + } + +} diff --git a/src/main/java/corecord/dev/HealthCheckController.java b/src/main/java/corecord/dev/HealthCheckController.java new file mode 100644 index 0000000..6b0681d --- /dev/null +++ b/src/main/java/corecord/dev/HealthCheckController.java @@ -0,0 +1,12 @@ +package corecord.dev; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HealthCheckController { + @GetMapping + public String welcome() { + return "Welcome to CORECORD!"; + } +} diff --git a/src/main/java/corecord/dev/common/base/BaseEntity.java b/src/main/java/corecord/dev/common/base/BaseEntity.java new file mode 100644 index 0000000..c664e4e --- /dev/null +++ b/src/main/java/corecord/dev/common/base/BaseEntity.java @@ -0,0 +1,30 @@ +package corecord.dev.common.base; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@EntityListeners(AuditingEntityListener.class) +@Getter @Setter +@MappedSuperclass +public class BaseEntity { + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; + + public String getCreatedAtFormatted() { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd"); + return createdAt.format(formatter); + } +} \ No newline at end of file diff --git a/src/main/java/corecord/dev/common/base/BaseErrorStatus.java b/src/main/java/corecord/dev/common/base/BaseErrorStatus.java new file mode 100644 index 0000000..b624491 --- /dev/null +++ b/src/main/java/corecord/dev/common/base/BaseErrorStatus.java @@ -0,0 +1,9 @@ +package corecord.dev.common.base; + +import org.springframework.http.HttpStatus; + +public interface BaseErrorStatus { + HttpStatus getHttpStatus(); + String getCode(); + String getMessage(); +} diff --git a/src/main/java/corecord/dev/common/base/BaseSuccessStatus.java b/src/main/java/corecord/dev/common/base/BaseSuccessStatus.java new file mode 100644 index 0000000..0f12797 --- /dev/null +++ b/src/main/java/corecord/dev/common/base/BaseSuccessStatus.java @@ -0,0 +1,9 @@ +package corecord.dev.common.base; + +import org.springframework.http.HttpStatus; + +public interface BaseSuccessStatus { + HttpStatus getHttpStatus(); + String getCode(); + String getMessage(); +} diff --git a/src/main/java/corecord/dev/common/config/CustomAccessDeniedHandler.java b/src/main/java/corecord/dev/common/config/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..c542b0d --- /dev/null +++ b/src/main/java/corecord/dev/common/config/CustomAccessDeniedHandler.java @@ -0,0 +1,27 @@ +package corecord.dev.common.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import corecord.dev.common.response.ApiResponse; +import corecord.dev.common.status.ErrorStatus; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, IOException { + ObjectMapper mapper = new ObjectMapper(); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + ApiResponse errorResponse = ApiResponse.error(ErrorStatus.FORBIDDEN).getBody(); + response.getWriter().write(mapper.writeValueAsString(errorResponse)); + } +} \ No newline at end of file diff --git a/src/main/java/corecord/dev/common/config/SecurityConfig.java b/src/main/java/corecord/dev/common/config/SecurityConfig.java new file mode 100644 index 0000000..6550091 --- /dev/null +++ b/src/main/java/corecord/dev/common/config/SecurityConfig.java @@ -0,0 +1,107 @@ +package corecord.dev.common.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import corecord.dev.common.response.ApiResponse; +import corecord.dev.common.status.ErrorStatus; +import corecord.dev.common.util.*; +import corecord.dev.domain.auth.handler.OAuthLoginFailureHandler; +import corecord.dev.domain.auth.handler.OAuthLoginSuccessHandler; +import corecord.dev.domain.auth.jwt.JwtFilter; +import corecord.dev.domain.auth.jwt.JwtUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.ExceptionTranslationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.Collections; +import java.util.stream.Stream; + +@RequiredArgsConstructor +@Configuration +@EnableWebSecurity +public class SecurityConfig { + private final JwtUtil jwtUtil; + private final CookieUtil cookieUtil; + private final OAuthLoginSuccessHandler oAuthLoginSuccessHandler; + private final OAuthLoginFailureHandler oAuthLoginFailureHandler; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + + + private final String[] swaggerUrls = {"/swagger-ui/**", "/v3/**"}; + private final String[] authUrls = {"/", "/api/users/register", "/oauth2/authorization/kakao", "/actuator/health", "/api/token/**", "/api/token"}; + private final String[] allowedUrls = Stream.concat(Arrays.stream(swaggerUrls), Arrays.stream(authUrls)) + .toArray(String[]::new); + + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(Arrays.asList( + "http://localhost:3000", + "http://localhost:5173", "https://localhost:5173", + "https://corecord.site", + "https://www.corecord.site", + "https://corecord.vercel.app" + )); + config.setAllowedMethods(Collections.singletonList("*")); + config.setAllowedHeaders(Collections.singletonList("*")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + + @Bean + public AuthenticationEntryPoint authenticationEntryPoint() { + return (request, response, authException) -> { + ObjectMapper mapper = new ObjectMapper(); + ErrorStatus errorStatus = ErrorStatus.UNAUTHORIZED; + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.setStatus(errorStatus.getHttpStatus().value()); + ApiResponse errorResponse = ApiResponse.error(ErrorStatus.UNAUTHORIZED).getBody(); + response.getWriter().write(mapper.writeValueAsString(errorResponse)); + }; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity httpSecurity, AuthenticationEntryPoint authenticationEntryPoint) throws Exception { + httpSecurity + .httpBasic(HttpBasicConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .requestCache(AbstractHttpConfigurer::disable) + .cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource())) + .csrf(AbstractHttpConfigurer::disable) + .exceptionHandling(exceptionHandlingConfigurer -> + exceptionHandlingConfigurer + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler) + ) + .addFilterBefore(new JwtFilter(jwtUtil, cookieUtil), ExceptionTranslationFilter.class) + .oauth2Login(oauth -> + oauth + .successHandler(oAuthLoginSuccessHandler) + .failureHandler(oAuthLoginFailureHandler) + ) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authorize -> + authorize + .requestMatchers(allowedUrls).permitAll() + .anyRequest().authenticated() + ); + return httpSecurity.build(); + } +} \ No newline at end of file diff --git a/src/main/java/corecord/dev/common/config/SwaggerConfig.java b/src/main/java/corecord/dev/common/config/SwaggerConfig.java new file mode 100644 index 0000000..212c4da --- /dev/null +++ b/src/main/java/corecord/dev/common/config/SwaggerConfig.java @@ -0,0 +1,46 @@ +package corecord.dev.common.config; + + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.servers.Server; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@OpenAPIDefinition( + info = @Info(title = "CO:RECORD", + description = "Kusitms 30th CO:RECORD", + version = "v1"), + servers = { + @Server(url = "https://api.corecord.site", + description = "서버 URL"), + } +) + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + SecurityRequirement securityRequirement = new SecurityRequirement().addList("bearerAuth"); + + Components components = new Components() + .addSecuritySchemes("bearerAuth", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name("Authorization") + ); + + return new OpenAPI() + .components(components) + .addSecurityItem(securityRequirement); + } + +} diff --git a/src/main/java/corecord/dev/common/config/WebConfig.java b/src/main/java/corecord/dev/common/config/WebConfig.java new file mode 100644 index 0000000..617627d --- /dev/null +++ b/src/main/java/corecord/dev/common/config/WebConfig.java @@ -0,0 +1,21 @@ +package corecord.dev.common.config; + +import corecord.dev.common.web.UserIdArgumentResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final UserIdArgumentResolver userIdArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(userIdArgumentResolver); + } +} diff --git a/src/main/java/corecord/dev/common/exception/GeneralException.java b/src/main/java/corecord/dev/common/exception/GeneralException.java new file mode 100644 index 0000000..b63424d --- /dev/null +++ b/src/main/java/corecord/dev/common/exception/GeneralException.java @@ -0,0 +1,14 @@ +package corecord.dev.common.exception; + +import corecord.dev.common.status.ErrorStatus; +import lombok.Getter; + +@Getter +public class GeneralException extends RuntimeException{ + private final ErrorStatus errorStatus; + + public GeneralException(ErrorStatus errorStatus) { + super(errorStatus.getMessage()); + this.errorStatus = errorStatus; + } +} diff --git a/src/main/java/corecord/dev/common/exception/GeneralExceptionAdvice.java b/src/main/java/corecord/dev/common/exception/GeneralExceptionAdvice.java new file mode 100644 index 0000000..1fd2e46 --- /dev/null +++ b/src/main/java/corecord/dev/common/exception/GeneralExceptionAdvice.java @@ -0,0 +1,173 @@ +package corecord.dev.common.exception; + +import corecord.dev.common.response.ApiResponse; +import corecord.dev.common.status.ErrorStatus; +import corecord.dev.domain.ability.exception.AbilityException; +import corecord.dev.domain.analysis.exception.AnalysisException; +import corecord.dev.domain.auth.exception.TokenException; +import corecord.dev.domain.chat.exception.ChatException; +import corecord.dev.domain.folder.exception.FolderException; +import corecord.dev.domain.record.exception.RecordException; +import corecord.dev.domain.user.exception.UserException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@RestControllerAdvice +public class GeneralExceptionAdvice extends ResponseEntityExceptionHandler { + + // UserException 처리 + @ExceptionHandler(UserException.class) + public ResponseEntity> handleUserException(UserException e) { + log.warn(">>>>>>>>UserException: {}", e.getUserErrorStatus().getMessage()); + return ApiResponse.error(e.getUserErrorStatus()); + } + + // TokenException 처리 + @ExceptionHandler(TokenException.class) + public ResponseEntity> handleTokenException(TokenException e) { + log.warn(">>>>>>>>TokenException: {}", e.getTokenErrorStatus().getMessage()); + return ApiResponse.error(e.getTokenErrorStatus()); + } + + // FolderException 처리 + @ExceptionHandler(FolderException.class) + public ResponseEntity> handleFolderException(FolderException e) { + log.warn(">>>>>>>>FolderException: {}", e.getFolderErrorStatus().getMessage()); + return ApiResponse.error(e.getFolderErrorStatus()); + } + + // RecordException 처리 + @ExceptionHandler(RecordException.class) + public ResponseEntity> handleRecordException(RecordException e) { + log.warn(">>>>>>>>RecordException: {}", e.getRecordErrorStatus().getMessage()); + return ApiResponse.error(e.getRecordErrorStatus()); + } + + // AnalysisException 처리 + @ExceptionHandler(AnalysisException.class) + public ResponseEntity> handleAnalysisException(AnalysisException e) { + log.warn(">>>>>>>>AnalysisException: {}", e.getAnalysisErrorStatus().getMessage()); + return ApiResponse.error(e.getAnalysisErrorStatus()); + } + + // AbilityException 처리 + @ExceptionHandler(AbilityException.class) + public ResponseEntity> handleAbilityException(AbilityException e) { + log.warn(">>>>>>>>AbilityException: {}", e.getAbilityErrorStatus().getMessage()); + return ApiResponse.error(e.getAbilityErrorStatus()); + } + + // ChatException 처리 + @ExceptionHandler(ChatException.class) + public ResponseEntity> handleChatException(ChatException e) { + log.warn(">>>>>>>>ChatException: {}", e.getChatErrorStatus().getMessage()); + return ApiResponse.error(e.getChatErrorStatus()); + } + + // GeneralException 처리 + @ExceptionHandler(GeneralException.class) + public ResponseEntity> handleGeneralException(GeneralException e) { + log.warn(">>>>>>>>GeneralException: {}", e.getErrorStatus().getMessage()); + return ApiResponse.error(e.getErrorStatus()); + } + + // HttpRequestMethodNotSupportedException 처리 (지원하지 않는 HTTP 메소드 요청이 들어온 경우) + @Override + protected ResponseEntity handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + String errorMessage = "지원하지 않는 HTTP 메소드 요청입니다: " + ex.getMethod(); + logError("HttpRequestMethodNotSupportedException", errorMessage); + return ApiResponse.error(ErrorStatus.METHOD_NOT_ALLOWED, errorMessage); + } + + // MissingServletRequestParameterException 처리 (필수 쿼리 파라미터가 입력되지 않은 경우) + @Override + protected ResponseEntity handleMissingServletRequestParameter(MissingServletRequestParameterException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + String errorMessage = "필수 파라미터 '" + ex.getParameterName() + "'가 없습니다."; + logError("MissingServletRequestParameterException", errorMessage); + return ApiResponse.error(ErrorStatus.BAD_REQUEST, errorMessage); + } + + // MethodArgumentNotValidException 처리 (RequestBody로 들어온 필드들의 유효성 검증에 실패한 경우) + @Override + protected ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + String combinedErrors = extractFieldErrors(ex.getBindingResult().getFieldErrors()); + logError("Validation error", combinedErrors); + return ApiResponse.error(ErrorStatus.BAD_REQUEST, combinedErrors); + } + + // NullPointerException 처리 + @ExceptionHandler(NullPointerException.class) + public ResponseEntity handleNullPointerException(NullPointerException e) { + String errorMessage = "서버에서 예기치 않은 오류가 발생했습니다. 요청을 처리하는 중에 Null 값이 참조되었습니다."; + logError("NullPointerException", e); + return ApiResponse.error(ErrorStatus.INTERNAL_SERVER_ERROR, errorMessage); + } + + // IllegalArgumentException 처리 (잘못된 인자가 전달된 경우) + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { + String errorMessage = "잘못된 요청입니다: " + e.getMessage(); + logError("IllegalArgumentException", errorMessage); + return ApiResponse.error(ErrorStatus.BAD_REQUEST, errorMessage); + } + + // MissingRequestHeaderException 처리 (필수 헤더가 누락된 경우) + @ExceptionHandler(MissingRequestHeaderException.class) + public ResponseEntity handleMissingRequestHeaderException(MissingRequestHeaderException ex) { + String errorMessage = "필수 헤더 '" + ex.getHeaderName() + "'가 없습니다."; + logError("MissingRequestHeaderException", errorMessage); + return ApiResponse.error(ErrorStatus.BAD_REQUEST, errorMessage); + } + + // Security 인증 관련 처리 + @ExceptionHandler(SecurityException.class) + public ResponseEntity> handleSecurityException(SecurityException e) { + logError(e.getMessage(), e); + return ApiResponse.error(ErrorStatus.UNAUTHORIZED); + } + + // 기타 모든 예외 처리 + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception e) { + log.error(">>>>>>>>Internal Server Error: {}", e.getMessage()); + e.printStackTrace(); + return ApiResponse.error(ErrorStatus.INTERNAL_SERVER_ERROR); + } + + // 로그 기록 메서드 + private void logError(String message, Object errorDetails) { + log.error("{}: {}", message, errorDetails); + } + + // 유효성 검증 오류 메시지 추출 메서드 (FieldErrors) + private String extractFieldErrors(List fieldErrors) { + return fieldErrors.stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + } +} \ No newline at end of file diff --git a/src/main/java/corecord/dev/common/response/ApiResponse.java b/src/main/java/corecord/dev/common/response/ApiResponse.java new file mode 100644 index 0000000..b06cf1d --- /dev/null +++ b/src/main/java/corecord/dev/common/response/ApiResponse.java @@ -0,0 +1,54 @@ +package corecord.dev.common.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import corecord.dev.common.base.BaseErrorStatus; +import corecord.dev.common.base.BaseSuccessStatus; +import corecord.dev.common.status.ErrorStatus; +import corecord.dev.common.status.SuccessStatus; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; + +@Getter +@RequiredArgsConstructor +@JsonPropertyOrder({"isSuccess", "code", "message", "data"}) +public class ApiResponse { + @JsonProperty("is_success") + private final Boolean isSuccess; // 성공 여부 + private final String code; // 사용자 정의 코드 (e.g., S001, E999) + private final String message; // 응답 메시지 + @JsonInclude(JsonInclude.Include.NON_NULL) + private final T data; // 응답 데이터 + + // 성공 응답 (데이터 없음) + public static ResponseEntity> success(BaseSuccessStatus successStatus) { + return ResponseEntity.status(successStatus.getHttpStatus()) + .body(new ApiResponse<>(true, successStatus.getCode(), successStatus.getMessage(), null)); + } + + // 성공 응답 (데이터 있음) + public static ResponseEntity> success(BaseSuccessStatus successStatus, T data) { + return ResponseEntity.status(successStatus.getHttpStatus()) + .body(new ApiResponse<>(true, successStatus.getCode(), successStatus.getMessage(), data)); + } + + // 에러 응답 (데이터 없음) + public static ResponseEntity> error(BaseErrorStatus errorStatus) { + return ResponseEntity.status(errorStatus.getHttpStatus()) + .body(new ApiResponse<>(false, errorStatus.getCode(), errorStatus.getMessage(), null)); + } + + // 에러 응답 (데이터 있음) + public static ResponseEntity> error(BaseErrorStatus errorStatus, T data) { + return ResponseEntity.status(errorStatus.getHttpStatus()) + .body(new ApiResponse<>(false, errorStatus.getCode(), errorStatus.getMessage(), data)); + } + + // 에러 응답 (오버라이드 메서드용) + public static ResponseEntity error(BaseErrorStatus errorStatus, String message) { + return ResponseEntity.status(errorStatus.getHttpStatus()) + .body(new ApiResponse<>(false, errorStatus.getCode(), message, null)); + } +} diff --git a/src/main/java/corecord/dev/common/status/ErrorStatus.java b/src/main/java/corecord/dev/common/status/ErrorStatus.java new file mode 100644 index 0000000..ac12a90 --- /dev/null +++ b/src/main/java/corecord/dev/common/status/ErrorStatus.java @@ -0,0 +1,39 @@ +package corecord.dev.common.status; + +import corecord.dev.common.base.BaseErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorStatus implements BaseErrorStatus { + + /** + * Error Code + * 400 : 잘못된 요청 + * 401 : JWT에 대한 오류 + * 403 : 요청한 정보에 대한 권한 없음. + * 404 : 존재하지 않는 정보에 대한 요청. + */ + + BAD_REQUEST(HttpStatus.BAD_REQUEST, "E0400", "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "E0401", "인증이 필요합니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, "E0403", "접근 권한이 없습니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, "E0404", "요청한 자원을 찾을 수 없습니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "E0405", "허용되지 않은 메소드입니다."), + AI_RESPONSE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E0500_CHAT_AI_RESPONSE_ERROR", "AI 응답 생성 중 오류가 발생했습니다."), + AI_CLIENT_ERROR(HttpStatus.BAD_REQUEST, "E0400_AI_CLIENT_ERROR", "AI 클라이언트 요청 오류가 발생했습니다."), + AI_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E0500_CHAT_SERVER_ERROR", "AI 서버에 오류가 발생했습니다."), + + + /** + * Error Code + * 500 : 서버 내부 오류 + */ + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E9999", "서버 내부 오류입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/corecord/dev/common/status/SuccessStatus.java b/src/main/java/corecord/dev/common/status/SuccessStatus.java new file mode 100644 index 0000000..6e776cf --- /dev/null +++ b/src/main/java/corecord/dev/common/status/SuccessStatus.java @@ -0,0 +1,19 @@ +package corecord.dev.common.status; + +import corecord.dev.common.base.BaseSuccessStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum SuccessStatus implements BaseSuccessStatus { + + // 전역 + OK(HttpStatus.OK, "S001", "요청이 성공적으로 처리되었습니다."), + CREATED(HttpStatus.CREATED, "S002", "리소스가 성공적으로 생성되었습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/corecord/dev/common/util/CookieUtil.java b/src/main/java/corecord/dev/common/util/CookieUtil.java new file mode 100644 index 0000000..d19082e --- /dev/null +++ b/src/main/java/corecord/dev/common/util/CookieUtil.java @@ -0,0 +1,48 @@ +package corecord.dev.common.util; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class CookieUtil { + public ResponseCookie createTokenCookie(String tokenName, String token, long expirationTime) { + return ResponseCookie.from(tokenName, token) + .domain("corecord.site") + .httpOnly(true) + .secure(true) // 배포 시 true로 설정 + .sameSite("None") + .path("/") + .maxAge(expirationTime / 1000) // maxAge는 초 단위 + .build(); + } + + public String getCookieValue(HttpServletRequest request, String cookieName) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals(cookieName)) { + return cookie.getValue(); + } + } + } + return null; + } + + public ResponseCookie deleteCookie(String cookieName) { + return ResponseCookie.from(cookieName, "") + .domain("corecord.site") + .httpOnly(true) + .secure(true) // 배포 시 true로 설정 + .sameSite("None") + .path("/") + .maxAge(0) + .build(); + } +} diff --git a/src/main/java/corecord/dev/common/util/ResourceLoader.java b/src/main/java/corecord/dev/common/util/ResourceLoader.java new file mode 100644 index 0000000..f2806c7 --- /dev/null +++ b/src/main/java/corecord/dev/common/util/ResourceLoader.java @@ -0,0 +1,18 @@ +package corecord.dev.common.util; + +import corecord.dev.common.exception.GeneralException; +import corecord.dev.common.status.ErrorStatus; +import org.springframework.core.io.ClassPathResource; +import java.nio.charset.StandardCharsets; + +public class ResourceLoader { + + public static String getResourceContent(String resourcePath) { + try { + var resource = new ClassPathResource(resourcePath); + return new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new GeneralException(ErrorStatus.NOT_FOUND); + } + } +} diff --git a/src/main/java/corecord/dev/common/web/UserId.java b/src/main/java/corecord/dev/common/web/UserId.java new file mode 100644 index 0000000..2b0fba2 --- /dev/null +++ b/src/main/java/corecord/dev/common/web/UserId.java @@ -0,0 +1,12 @@ +package corecord.dev.common.web; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface UserId { + +} diff --git a/src/main/java/corecord/dev/common/web/UserIdArgumentResolver.java b/src/main/java/corecord/dev/common/web/UserIdArgumentResolver.java new file mode 100644 index 0000000..426e5a8 --- /dev/null +++ b/src/main/java/corecord/dev/common/web/UserIdArgumentResolver.java @@ -0,0 +1,33 @@ +package corecord.dev.common.web; + +import corecord.dev.common.exception.GeneralException; +import corecord.dev.common.status.ErrorStatus; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +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; + +@Component +public class UserIdArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterAnnotation(UserId.class) != null && + parameter.getParameterType().equals(Long.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + throw new GeneralException(ErrorStatus.UNAUTHORIZED); + } else { + Long userId = Long.valueOf(authentication.getPrincipal().toString()); + return userId; + } + } +} \ No newline at end of file diff --git a/src/main/java/corecord/dev/domain/ability/application/AbilityService.java b/src/main/java/corecord/dev/domain/ability/application/AbilityService.java new file mode 100644 index 0000000..47e0369 --- /dev/null +++ b/src/main/java/corecord/dev/domain/ability/application/AbilityService.java @@ -0,0 +1,107 @@ +package corecord.dev.domain.ability.application; + +import corecord.dev.common.exception.GeneralException; +import corecord.dev.common.status.ErrorStatus; +import corecord.dev.domain.ability.domain.converter.AbilityConverter; +import corecord.dev.domain.ability.domain.dto.response.AbilityResponse; +import corecord.dev.domain.ability.domain.entity.Ability; +import corecord.dev.domain.ability.domain.entity.Keyword; +import corecord.dev.domain.ability.status.AbilityErrorStatus; +import corecord.dev.domain.ability.exception.AbilityException; +import corecord.dev.domain.ability.domain.repository.AbilityRepository; +import corecord.dev.domain.analysis.domain.entity.Analysis; +import corecord.dev.domain.user.domain.entity.User; +import corecord.dev.domain.user.domain.repository.UserRepository; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class AbilityService { + private final AbilityRepository abilityRepository; + private final UserRepository userRepository; + private final EntityManager entityManager; + + /* + * user의 역량 키워드 리스트를 반환 + * @param userId + * @return + */ + @Transactional(readOnly = true) + public AbilityResponse.KeywordListDto getKeywordList(Long userId) { + User user = findUserById(userId); + List keywordList = findKeywordList(user); + + return AbilityConverter.toKeywordListDto(keywordList); + } + + /* + * user의 각 역량 키워드에 대한 개수, 퍼센티지 정보를 반환 + * @param userId + * @return + */ + @Transactional(readOnly = true) + public AbilityResponse.GraphDto getKeywordGraph(Long userId) { + User user = findUserById(userId); + + // keyword graph 정보 조회 + List keywordGraph = findKeywordGraph(user); + + return AbilityConverter.toGraphDto(keywordGraph); + } + + // CLOVA STUDIO를 통해 얻은 키워드 정보 파싱 + @Transactional + public void parseAndSaveAbilities(Map keywordList, Analysis analysis, User user) { + int abilityCount = 0; + for (Map.Entry entry : keywordList.entrySet()) { + Keyword keyword = Keyword.getName(entry.getKey()); + + if (keyword == null) continue; + + Ability ability = AbilityConverter.toAbility(keyword, entry.getValue(), analysis, user); + abilityRepository.save(ability); + if (analysis.getAbilityList() != null) + analysis.addAbility(ability); + abilityCount++; + } + + if (abilityCount < 1 || abilityCount > 3) { + throw new AbilityException(AbilityErrorStatus.INVALID_ABILITY_KEYWORD); + } + } + + @Transactional + public void deleteOriginAbilityList(Analysis analysis) { + List abilityList = analysis.getAbilityList(); + + if (!abilityList.isEmpty()) { + // 연관된 abilities 삭제 + abilityRepository.deleteAll(abilityList); + + // Analysis에서 abilities 리스트 비우기 + analysis.getAbilityList().clear(); + entityManager.flush(); + } + } + + private List findKeywordList(User user) { + return abilityRepository.getKeywordList(user).stream() + .map(Keyword::getValue) + .toList(); + } + + private List findKeywordGraph(User user) { + return abilityRepository.findKeywordStateDtoList(user); + } + + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.UNAUTHORIZED)); + } +} diff --git a/src/main/java/corecord/dev/domain/ability/domain/converter/AbilityConverter.java b/src/main/java/corecord/dev/domain/ability/domain/converter/AbilityConverter.java new file mode 100644 index 0000000..cbdeb38 --- /dev/null +++ b/src/main/java/corecord/dev/domain/ability/domain/converter/AbilityConverter.java @@ -0,0 +1,40 @@ +package corecord.dev.domain.ability.domain.converter; + +import corecord.dev.domain.ability.domain.dto.response.AbilityResponse; +import corecord.dev.domain.ability.domain.entity.Ability; +import corecord.dev.domain.ability.domain.entity.Keyword; +import corecord.dev.domain.analysis.domain.entity.Analysis; +import corecord.dev.domain.user.domain.entity.User; + +import java.util.List; + +public class AbilityConverter { + + public static Ability toAbility(Keyword keyword, String content, Analysis analysis, User user) { + return Ability.builder() + .keyword(keyword) + .content(content) + .analysis(analysis) + .user(user) + .build(); + } + + public static AbilityResponse.AbilityDto toAbilityDto(Ability ability) { + return AbilityResponse.AbilityDto.builder() + .keyword(ability.getKeyword().getValue()) + .content(ability.getContent()) + .build(); + } + + public static AbilityResponse.KeywordListDto toKeywordListDto(List keywordList) { + return AbilityResponse.KeywordListDto.builder() + .keywordList(keywordList) + .build(); + } + + public static AbilityResponse.GraphDto toGraphDto(List keywordStateDtoList) { + return AbilityResponse.GraphDto.builder() + .keywordGraph(keywordStateDtoList) + .build(); + } +} diff --git a/src/main/java/corecord/dev/domain/ability/domain/dto/response/AbilityResponse.java b/src/main/java/corecord/dev/domain/ability/domain/dto/response/AbilityResponse.java new file mode 100644 index 0000000..bbb7ccb --- /dev/null +++ b/src/main/java/corecord/dev/domain/ability/domain/dto/response/AbilityResponse.java @@ -0,0 +1,50 @@ +package corecord.dev.domain.ability.domain.dto.response; + +import corecord.dev.domain.ability.domain.entity.Keyword; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Getter; + +import java.util.List; + +public class AbilityResponse { + + @Data + @Builder + @Getter + @AllArgsConstructor + public static class AbilityDto { + private String keyword; + private String content; + } + + @Builder + @Getter + @AllArgsConstructor + @Data + public static class KeywordListDto { + private List keywordList; + } + + @Data + public static class KeywordStateDto { + private String keyword; + private Long count; + private Long percent; + + public KeywordStateDto(Keyword keyword, Long count, Double percent) { + this.keyword = keyword.getValue(); + this.count = count; + this.percent = Math.round(percent); + } + } + + @Builder + @Getter + @AllArgsConstructor + @Data + public static class GraphDto { + List keywordGraph; + } +} diff --git a/src/main/java/corecord/dev/domain/ability/domain/entity/Ability.java b/src/main/java/corecord/dev/domain/ability/domain/entity/Ability.java new file mode 100644 index 0000000..a9658bd --- /dev/null +++ b/src/main/java/corecord/dev/domain/ability/domain/entity/Ability.java @@ -0,0 +1,44 @@ +package corecord.dev.domain.ability.domain.entity; + +import corecord.dev.common.base.BaseEntity; +import corecord.dev.domain.analysis.domain.entity.Analysis; +import corecord.dev.domain.user.domain.entity.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Ability extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(nullable = false) + private Long abilityId; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private Keyword keyword; + + @Column(nullable = false, length = 300) + private String content; + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "analysis_id", nullable = false) + private Analysis analysis; + + public void updateContent(String content) { + if (content != null && !content.isEmpty()) { + this.content = content; + } + } +} diff --git a/src/main/java/corecord/dev/domain/ability/domain/entity/Keyword.java b/src/main/java/corecord/dev/domain/ability/domain/entity/Keyword.java new file mode 100644 index 0000000..af9dd6d --- /dev/null +++ b/src/main/java/corecord/dev/domain/ability/domain/entity/Keyword.java @@ -0,0 +1,34 @@ +package corecord.dev.domain.ability.domain.entity; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum Keyword { + PROBLEM_SOLViNG_SKILL("문제해결능력"), ANALYTICAL_SKILL("분석력"), + GLOBAL_SKILL("글로벌역량"), JUDGEMENT_SKILL("판단력"), + + TARGET_AWARENESS("목표의식"), MOMENTUM("추진력"), + + COMMUNICATION("커뮤니케이션"), LEADERSHIP("리더십"), + COLLABORATION("협업능력"), ADAPTABILITY("적응력"), + + CREATIVITY("창의성"), LOGIC("논리성"), + CHALLENGE_MINDSET("도전정신"), SELF_IMPROVEMENT("자기계발"), RESPONSIBILITY("책임감") + ; + + private final String value; + + public String getValue() { + return value; + } + + public static Keyword getName(String value){ + for (Keyword keyword : Keyword.values()) { + if (keyword.getValue().equals(value)) { + return keyword; + } + } + return null; + } + +} diff --git a/src/main/java/corecord/dev/domain/ability/domain/repository/AbilityRepository.java b/src/main/java/corecord/dev/domain/ability/domain/repository/AbilityRepository.java new file mode 100644 index 0000000..43c1cdc --- /dev/null +++ b/src/main/java/corecord/dev/domain/ability/domain/repository/AbilityRepository.java @@ -0,0 +1,39 @@ +package corecord.dev.domain.ability.domain.repository; + +import corecord.dev.domain.ability.domain.dto.response.AbilityResponse; +import corecord.dev.domain.ability.domain.entity.Ability; +import corecord.dev.domain.ability.domain.entity.Keyword; +import corecord.dev.domain.user.domain.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface AbilityRepository extends JpaRepository { + @Query("SELECT new corecord.dev.domain.ability.domain.dto.response.AbilityResponse$KeywordStateDto(" + + "a.keyword, COUNT(a), " + // 각 키워드의 개수 집계 + "(COUNT(a) * 1.0 / (SELECT COUNT(a2) FROM Ability a2 WHERE a2.user = :user)) * 100.0) " + // 각 키워드의 비율 집계 + "FROM Ability a " + + "WHERE a.user = :user " + + "GROUP BY a.keyword " + + "ORDER BY 3 desc ") // 비율 높은 순 정렬 + List findKeywordStateDtoList(@Param(value = "user") User user); + + @Modifying + @Query("DELETE " + + "FROM Ability a " + + "WHERE a.user.userId IN :userId") + void deleteAbilityByUserId(@Param(value = "userId") Long userId); + + @Query("SELECT distinct a.keyword AS keyword " + // unique한 keyword list 반환 + "FROM Ability a " + + "JOIN a.analysis ana " + + "WHERE a.user = :user " + + "GROUP BY a.keyword " + + "ORDER BY COUNT(a.keyword) DESC, MAX(ana.createdAt) DESC ") // 개수가 많은 순, 최근 생성 순 정렬 + List getKeywordList(@Param(value = "user") User user); +} diff --git a/src/main/java/corecord/dev/domain/ability/exception/AbilityException.java b/src/main/java/corecord/dev/domain/ability/exception/AbilityException.java new file mode 100644 index 0000000..6a27b9c --- /dev/null +++ b/src/main/java/corecord/dev/domain/ability/exception/AbilityException.java @@ -0,0 +1,17 @@ +package corecord.dev.domain.ability.exception; + +import corecord.dev.domain.ability.status.AbilityErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class AbilityException extends RuntimeException { + + private final AbilityErrorStatus abilityErrorStatus; + + @Override + public String getMessage() { + return abilityErrorStatus.getMessage(); + } +} diff --git a/src/main/java/corecord/dev/domain/ability/presentation/AbilityController.java b/src/main/java/corecord/dev/domain/ability/presentation/AbilityController.java new file mode 100644 index 0000000..fdba5e5 --- /dev/null +++ b/src/main/java/corecord/dev/domain/ability/presentation/AbilityController.java @@ -0,0 +1,36 @@ +package corecord.dev.domain.ability.presentation; + +import corecord.dev.common.response.ApiResponse; +import corecord.dev.common.web.UserId; +import corecord.dev.domain.ability.status.AbilitySuccessStatus; +import corecord.dev.domain.ability.domain.dto.response.AbilityResponse; +import corecord.dev.domain.ability.application.AbilityService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/keyword") +public class AbilityController { + private final AbilityService abilityService; + + @GetMapping("") + public ResponseEntity> getKeywordList( + @UserId Long userId + ) { + AbilityResponse.KeywordListDto response = abilityService.getKeywordList(userId); + return ApiResponse.success(AbilitySuccessStatus.KEYWORD_LIST_GET_SUCCESS, response); + } + + @GetMapping("/graph") + public ResponseEntity> getKeywordGraph( + @UserId Long userId + ) { + AbilityResponse.GraphDto response = abilityService.getKeywordGraph(userId); + return ApiResponse.success(AbilitySuccessStatus.KEYWORD_GRAPH_GET_SUCCESS, response); + } + +} diff --git a/src/main/java/corecord/dev/domain/ability/status/AbilityErrorStatus.java b/src/main/java/corecord/dev/domain/ability/status/AbilityErrorStatus.java new file mode 100644 index 0000000..fda9a9c --- /dev/null +++ b/src/main/java/corecord/dev/domain/ability/status/AbilityErrorStatus.java @@ -0,0 +1,20 @@ +package corecord.dev.domain.ability.status; + +import corecord.dev.common.base.BaseErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AbilityErrorStatus implements BaseErrorStatus { + INVALID_KEYWORD(HttpStatus.BAD_REQUEST, "E400_INVALID_KEYWORD", "역량 분석에 존재하지 않는 키워드입니다."), + INVALID_ABILITY_KEYWORD(HttpStatus.INTERNAL_SERVER_ERROR, "E500_INVALID_ABILITY_KEYWORD", "역량 키워드 파싱 중 오류가 발생했습니다."), + ; + + + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/corecord/dev/domain/ability/status/AbilitySuccessStatus.java b/src/main/java/corecord/dev/domain/ability/status/AbilitySuccessStatus.java new file mode 100644 index 0000000..dbf4272 --- /dev/null +++ b/src/main/java/corecord/dev/domain/ability/status/AbilitySuccessStatus.java @@ -0,0 +1,18 @@ +package corecord.dev.domain.ability.status; + +import corecord.dev.common.base.BaseSuccessStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AbilitySuccessStatus implements BaseSuccessStatus { + KEYWORD_LIST_GET_SUCCESS(HttpStatus.OK, "S505", "역량 키워드 리스트 조회가 성공적으로 완료되었습니다."), + KEYWORD_GRAPH_GET_SUCCESS(HttpStatus.OK, "S202", "역량 키워드 그래프 조회가 성공적으로 완료되었습니다.") + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/corecord/dev/domain/analysis/application/AnalysisService.java b/src/main/java/corecord/dev/domain/analysis/application/AnalysisService.java new file mode 100644 index 0000000..4c41d2a --- /dev/null +++ b/src/main/java/corecord/dev/domain/analysis/application/AnalysisService.java @@ -0,0 +1,262 @@ +package corecord.dev.domain.analysis.application; + +import corecord.dev.common.exception.GeneralException; +import corecord.dev.common.status.ErrorStatus; +import corecord.dev.domain.ability.domain.entity.Keyword; +import corecord.dev.domain.ability.status.AbilityErrorStatus; +import corecord.dev.domain.ability.exception.AbilityException; +import corecord.dev.domain.ability.application.AbilityService; +import corecord.dev.domain.analysis.domain.converter.AnalysisConverter; +import corecord.dev.domain.analysis.domain.dto.request.AnalysisRequest; +import corecord.dev.domain.analysis.infra.openai.dto.response.AnalysisAiResponse; +import corecord.dev.domain.analysis.domain.dto.response.AnalysisResponse; +import corecord.dev.domain.ability.domain.entity.Ability; +import corecord.dev.domain.analysis.domain.entity.Analysis; +import corecord.dev.domain.analysis.infra.openai.application.OpenAiService; +import corecord.dev.domain.analysis.status.AnalysisErrorStatus; +import corecord.dev.domain.analysis.exception.AnalysisException; +import corecord.dev.domain.analysis.domain.repository.AnalysisRepository; +import corecord.dev.domain.record.domain.entity.Record; +import corecord.dev.domain.record.status.RecordErrorStatus; +import corecord.dev.domain.record.exception.RecordException; +import corecord.dev.domain.record.domain.repository.RecordRepository; +import corecord.dev.domain.user.domain.entity.User; +import corecord.dev.domain.user.domain.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Transactional(readOnly = true) +@Service +@RequiredArgsConstructor +public class AnalysisService { + private final AnalysisRepository analysisRepository; + private final UserRepository userRepository; + private final RecordRepository recordRepository; + private final OpenAiService openAiService; + private final AbilityService abilityService; + + + /* + * CLOVA STUDIO룰 활용해 역량 분석 객체를 생성 후 반환 + * @param record + * @param user + * @return + */ + @Transactional + public Analysis createAnalysis(Record record, User user) { + + // MEMO 경험 기록이라면, CLOVA STUDIO를 이용해 요약 진행 + String content = getRecordContent(record); + + // Open-ai API 호출 + AnalysisAiResponse response = generateAbilityAnalysis(content); + + // Analysis 객체 생성 및 저장 + Analysis analysis = AnalysisConverter.toAnalysis(content, response.getComment(), record); + analysisRepository.save(analysis); + + // Ability 객체 생성 및 저장 + abilityService.parseAndSaveAbilities(response.getKeywordList(), analysis, user); + + return analysis; + } + + /* + * CLOVA STUDIO를 활용해 역량 분석을 재수행함 Analysis 객체 데이터 교체 후 반환 + * @param record + * @param user + * @return + */ + @Transactional + public Analysis recreateAnalysis(Record record, User user) { + Analysis analysis = record.getAnalysis(); + + // MEMO 경험 기록이라면, CLOVA STUDIO를 이용해 요약 진행 + String content = getRecordContent(record); + + // Open-ai API 호출 + AnalysisAiResponse response = generateAbilityAnalysis(content); + + // Analysis 객체 수정 + analysis.updateContent(content); + analysis.updateComment(response.getComment()); + + // 기존 Ability 객체 삭제 + abilityService.deleteOriginAbilityList(analysis); + + // Ability 객체 생성 및 저장 + abilityService.parseAndSaveAbilities(response.getKeywordList(), analysis, user); + + return analysis; + } + + /* + * recordId를 받아, 해당 경험 기록에 대한 역량 분석을 수행 후 생성된 역량 분석 상세 정보를 반환 + * @param userId + * @param recordId + * @return + */ + public AnalysisResponse.AnalysisDto postAnalysis(Long userId, Long recordId) { + User user = findUserById(userId); + Record record = findRecordById(recordId); + + // User-Record 권한 유효성 검증 + validIsUserAuthorizedForRecord(user, record); + + // 역량 분석 API 호출 + Analysis analysis = record.getAnalysis() == null ? + createAnalysis(record, user) : + recreateAnalysis(record, user); // 기존 Analysis 객체가 있을 경우 교체 + + return AnalysisConverter.toAnalysisDto(analysis); + } + + /* + * analysisId를 받아 경험 분석 상세 정보를 반환 + * @param userId, analysisId + * @return + */ + @Transactional(readOnly = true) + public AnalysisResponse.AnalysisDto getAnalysis(Long userId, Long analysisId) { + User user = findUserById(userId); + Analysis analysis = findAnalysisById(analysisId); + + // User-Analysis 권한 유효성 검증 + validIsUserAuthorizedForAnalysis(user, analysis); + + return AnalysisConverter.toAnalysisDto(analysis); + } + + /* + * 역량 분석의 기록 내용 혹은 각 키워드에 대한 내용을 수정한 후 수정된 역량 분석 정보를 반환 + * @param userId, analysisUpdateDto + * @return + */ + @Transactional + public AnalysisResponse.AnalysisDto updateAnalysis(Long userId, AnalysisRequest.AnalysisUpdateDto analysisUpdateDto) { + User user = findUserById(userId); + Analysis analysis = findAnalysisById(analysisUpdateDto.getAnalysisId()); + + // User-Analysis 권한 유효성 검증 + validIsUserAuthorizedForAnalysis(user, analysis); + + // 경험 기록 제목 수정 + String title = analysisUpdateDto.getTitle(); + analysis.getRecord().updateTitle(title); + + // 경험 역량 분석 요약 내용 수정 + String content = analysisUpdateDto.getContent(); + analysis.updateContent(content); + + // 키워드 경험 내용 수정 + Map abilityMap = analysisUpdateDto.getAbilityMap(); + updateAbilityContents(analysis, abilityMap); + + return AnalysisConverter.toAnalysisDto(analysis); + } + + /* + * analysisId를 받아 역량 분석, 경험 기록 데이터를 제거 + * @param userId, analysisId + */ + @Transactional + public void deleteAnalysis(Long userId, Long analysisId) { + User user = findUserById(userId); + Analysis analysis = findAnalysisById(analysisId); + + // User-Analysis 권한 유효성 검증 + validIsUserAuthorizedForAnalysis(user, analysis); + + analysisRepository.delete(analysis); + } + + private AnalysisAiResponse generateAbilityAnalysis(String content) { + AnalysisAiResponse response = openAiService.generateAbilityAnalysis(content); + + // 글자 수 validation + validAnalysisCommentLength(response.getComment()); + validAnalysisKeywordContentLength(response.getKeywordList()); + + return response; + } + + private String generateMemoSummary(String content) { + String response = openAiService.generateMemoSummary(content); + validAnalysisContentLength(response); + + return response; + } + + private String getRecordContent(Record record) { + String content = record.isMemoType() + ? generateMemoSummary(record.getContent()) + : record.getContent(); + + validAnalysisContentLength(content); + + return content; + } + + private void validIsUserAuthorizedForAnalysis(User user, Analysis analysis) { + if (!analysis.getRecord().getUser().equals(user)) + throw new RecordException(RecordErrorStatus.USER_RECORD_UNAUTHORIZED); + } + + private void validIsUserAuthorizedForRecord(User user, Record record) { + if (!record.getUser().equals(user)) + throw new RecordException(RecordErrorStatus.USER_RECORD_UNAUTHORIZED); + } + + private void validAnalysisContentLength(String content) { + if (content.isEmpty() || content.length() > 500) + throw new AnalysisException(AnalysisErrorStatus.OVERFLOW_ANALYSIS_CONTENT); + } + + private void validAnalysisCommentLength(String comment) { + if (comment.isEmpty() || comment.length() > 300) + throw new AnalysisException(AnalysisErrorStatus.OVERFLOW_ANALYSIS_COMMENT); + } + + private void validAnalysisKeywordContentLength(Map keywordList) { + for (Map.Entry entry : keywordList.entrySet()) { + String keyContent = entry.getValue(); + + if (keyContent.isEmpty() || keyContent.length() > 300) + throw new AnalysisException(AnalysisErrorStatus.OVERFLOW_ANALYSIS_KEYWORD_CONTENT); + } + } + + private void updateAbilityContents(Analysis analysis, Map abilityMap) { + abilityMap.forEach((keyword, content) -> { + Keyword key = Keyword.getName(keyword); + Ability ability = findAbilityByKeyword(analysis, key); + ability.updateContent(content); + }); + } + + private Ability findAbilityByKeyword(Analysis analysis, Keyword key) { + // keyword가 기존 역량 분석에 존재했는지 확인 + return analysis.getAbilityList().stream() + .filter(ability -> ability.getKeyword().equals(key)) + .findFirst() + .orElseThrow(() -> new AbilityException(AbilityErrorStatus.INVALID_KEYWORD)); + } + + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.UNAUTHORIZED)); + } + + private Analysis findAnalysisById(Long analysisId) { + return analysisRepository.findAnalysisById(analysisId) + .orElseThrow(() -> new AnalysisException(AnalysisErrorStatus.ANALYSIS_NOT_FOUND)); + } + + private Record findRecordById(Long recordId) { + return recordRepository.findRecordById(recordId) + .orElseThrow(() -> new RecordException(RecordErrorStatus.RECORD_NOT_FOUND)); + } +} diff --git a/src/main/java/corecord/dev/domain/analysis/domain/converter/AnalysisConverter.java b/src/main/java/corecord/dev/domain/analysis/domain/converter/AnalysisConverter.java new file mode 100644 index 0000000..6658a8b --- /dev/null +++ b/src/main/java/corecord/dev/domain/analysis/domain/converter/AnalysisConverter.java @@ -0,0 +1,41 @@ +package corecord.dev.domain.analysis.domain.converter; + +import corecord.dev.domain.ability.domain.converter.AbilityConverter; +import corecord.dev.domain.ability.domain.dto.response.AbilityResponse; +import corecord.dev.domain.analysis.domain.dto.response.AnalysisResponse; +import corecord.dev.domain.analysis.domain.entity.Analysis; +import corecord.dev.domain.record.domain.entity.RecordType; +import corecord.dev.domain.record.domain.entity.Record; + +import java.util.List; + +public class AnalysisConverter { + public static Analysis toAnalysis(String content, String comment, Record record) { + return Analysis.builder() + .content(content) + .comment(comment) + .record(record) + .build(); + } + + public static AnalysisResponse.AnalysisDto toAnalysisDto(Analysis analysis) { + Record record = analysis.getRecord(); + + // TODO: keyword 정렬 순서 고려 필요 + List abilityDtoList = analysis.getAbilityList().stream() + .map(AbilityConverter::toAbilityDto) + .toList(); + + return AnalysisResponse.AnalysisDto.builder() + .analysisId(analysis.getAnalysisId()) + .chatRoomId(record.getType() == RecordType.CHAT ? record.getChatRoom().getChatRoomId() : null) + .recordId(record.getRecordId()) + .recordType(record.getType()) + .recordTitle(record.getTitle()) + .recordContent(analysis.getContent()) + .abilityDtoList(abilityDtoList) + .comment(analysis.getComment()) + .createdAt(analysis.getCreatedAtFormatted()) + .build(); + } +} diff --git a/src/main/java/corecord/dev/domain/analysis/domain/dto/request/AnalysisRequest.java b/src/main/java/corecord/dev/domain/analysis/domain/dto/request/AnalysisRequest.java new file mode 100644 index 0000000..ae2a828 --- /dev/null +++ b/src/main/java/corecord/dev/domain/analysis/domain/dto/request/AnalysisRequest.java @@ -0,0 +1,19 @@ +package corecord.dev.domain.analysis.domain.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Data; + +import java.util.Map; + +public class AnalysisRequest { + + @Data @Builder + public static class AnalysisUpdateDto { + @NotNull(message = "역량 분석 id를 입력해주세요.") + private Long analysisId; + private String title; + private String content; + private Map abilityMap; + } +} diff --git a/src/main/java/corecord/dev/domain/analysis/domain/dto/response/AnalysisResponse.java b/src/main/java/corecord/dev/domain/analysis/domain/dto/response/AnalysisResponse.java new file mode 100644 index 0000000..9c3a9b2 --- /dev/null +++ b/src/main/java/corecord/dev/domain/analysis/domain/dto/response/AnalysisResponse.java @@ -0,0 +1,30 @@ +package corecord.dev.domain.analysis.domain.dto.response; + +import corecord.dev.domain.ability.domain.dto.response.AbilityResponse; +import corecord.dev.domain.record.domain.entity.RecordType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Getter; + +import java.util.List; + +public class AnalysisResponse { + + @Builder + @Getter + @AllArgsConstructor + @Data + public static class AnalysisDto { + private Long analysisId; + private Long chatRoomId; + private Long recordId; + private RecordType recordType; + private String recordTitle; + private String recordContent; + private List abilityDtoList; + private String comment; + private String createdAt; + } + +} diff --git a/src/main/java/corecord/dev/domain/analysis/domain/entity/Analysis.java b/src/main/java/corecord/dev/domain/analysis/domain/entity/Analysis.java new file mode 100644 index 0000000..f0526a3 --- /dev/null +++ b/src/main/java/corecord/dev/domain/analysis/domain/entity/Analysis.java @@ -0,0 +1,58 @@ +package corecord.dev.domain.analysis.domain.entity; + +import corecord.dev.common.base.BaseEntity; +import corecord.dev.domain.ability.domain.entity.Ability; +import corecord.dev.domain.record.domain.entity.Record; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; + +import java.util.List; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Analysis extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(nullable = false) + private Long analysisId; + + @Column(nullable = false, length = 500) + private String content; + + @Column(nullable = false, length = 300) + private String comment; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "record_id", nullable = true) + private Record record; + + @BatchSize(size = 3) + @OneToMany(mappedBy = "analysis", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private List abilityList; + + public void updateContent(String content) { + if (content != null && !content.isEmpty()) + this.content = content; + } + + public void updateComment(String comment) { + if (!comment.isEmpty()) { + this.comment = comment; + } + } + + public void addAbility(Ability ability) { + if (ability != null) { + this.abilityList.add(ability); + } + } + +} diff --git a/src/main/java/corecord/dev/domain/analysis/domain/repository/AnalysisRepository.java b/src/main/java/corecord/dev/domain/analysis/domain/repository/AnalysisRepository.java new file mode 100644 index 0000000..134c846 --- /dev/null +++ b/src/main/java/corecord/dev/domain/analysis/domain/repository/AnalysisRepository.java @@ -0,0 +1,27 @@ +package corecord.dev.domain.analysis.domain.repository; + +import corecord.dev.domain.analysis.domain.entity.Analysis; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface AnalysisRepository extends JpaRepository { + + @Query("SELECT a " + + "FROM Analysis a " + + "JOIN FETCH a.record r " + + "JOIN FETCH a.abilityList al " + + "WHERE a.analysisId = :id") + Optional findAnalysisById(@Param(value = "id") Long id); + + @Modifying + @Query("DELETE " + + "FROM Analysis a " + + "WHERE a.record.user.userId IN :userId") + void deleteAnalysisByUserId(@Param(value = "userId") Long userId); +} diff --git a/src/main/java/corecord/dev/domain/analysis/exception/AnalysisException.java b/src/main/java/corecord/dev/domain/analysis/exception/AnalysisException.java new file mode 100644 index 0000000..f01d093 --- /dev/null +++ b/src/main/java/corecord/dev/domain/analysis/exception/AnalysisException.java @@ -0,0 +1,16 @@ +package corecord.dev.domain.analysis.exception; + +import corecord.dev.domain.analysis.status.AnalysisErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class AnalysisException extends RuntimeException { + private final AnalysisErrorStatus analysisErrorStatus; + + @Override + public String getMessage() { + return analysisErrorStatus.getMessage(); + } +} diff --git a/src/main/java/corecord/dev/domain/analysis/infra/openai/application/OpenAiService.java b/src/main/java/corecord/dev/domain/analysis/infra/openai/application/OpenAiService.java new file mode 100644 index 0000000..4f4ba4b --- /dev/null +++ b/src/main/java/corecord/dev/domain/analysis/infra/openai/application/OpenAiService.java @@ -0,0 +1,37 @@ +package corecord.dev.domain.analysis.infra.openai.application; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import corecord.dev.common.util.ResourceLoader; +import corecord.dev.domain.analysis.infra.openai.dto.response.AnalysisAiResponse; +import corecord.dev.domain.analysis.status.AnalysisErrorStatus; +import corecord.dev.domain.analysis.exception.AnalysisException; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class OpenAiService { + private final OpenAiChatModel chatModel; + private static final String ABILITY_ANALYSIS_SYSTEM_CONTENT = ResourceLoader.getResourceContent("ability-analysis-prompt.txt"); + private static final String SUMMARY_SYSTEM_CONTENT = ResourceLoader.getResourceContent("memo-summary-prompt.txt"); + + public AnalysisAiResponse generateAbilityAnalysis(String content) { + String response = chatModel.call(ABILITY_ANALYSIS_SYSTEM_CONTENT + content); + return parseAnalysisAiResponse(response); + } + + public String generateMemoSummary(String content) { + return chatModel.call(SUMMARY_SYSTEM_CONTENT + content); + } + + private AnalysisAiResponse parseAnalysisAiResponse(String aiResponse) { + ObjectMapper objectMapper = new ObjectMapper(); + try { + return objectMapper.readValue(aiResponse, AnalysisAiResponse.class); + } catch (JsonProcessingException e) { + throw new AnalysisException(AnalysisErrorStatus.INVALID_ABILITY_ANALYSIS); + } + } +} diff --git a/src/main/java/corecord/dev/domain/analysis/infra/openai/config/OpenAIConfig.java b/src/main/java/corecord/dev/domain/analysis/infra/openai/config/OpenAIConfig.java new file mode 100644 index 0000000..7629d18 --- /dev/null +++ b/src/main/java/corecord/dev/domain/analysis/infra/openai/config/OpenAIConfig.java @@ -0,0 +1,15 @@ +package corecord.dev.domain.analysis.infra.openai.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenAIConfig { + + @Value("${spring.ai.openai.api-key}") + private String apiKey; + + @Value("${spring.ai.openai.chat.options.model}") + private String model; + +} diff --git a/src/main/java/corecord/dev/domain/analysis/infra/openai/dto/response/AnalysisAiResponse.java b/src/main/java/corecord/dev/domain/analysis/infra/openai/dto/response/AnalysisAiResponse.java new file mode 100644 index 0000000..8f8b8dc --- /dev/null +++ b/src/main/java/corecord/dev/domain/analysis/infra/openai/dto/response/AnalysisAiResponse.java @@ -0,0 +1,18 @@ +package corecord.dev.domain.analysis.infra.openai.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.*; + +import java.util.Map; + +@Setter +@Getter @Data +@NoArgsConstructor +@AllArgsConstructor +public class AnalysisAiResponse { + @JsonProperty("keywordList") + private Map keywordList; + + @JsonProperty("comment") + private String comment; +} diff --git a/src/main/java/corecord/dev/domain/analysis/presentation/AnalysisController.java b/src/main/java/corecord/dev/domain/analysis/presentation/AnalysisController.java new file mode 100644 index 0000000..930fcf2 --- /dev/null +++ b/src/main/java/corecord/dev/domain/analysis/presentation/AnalysisController.java @@ -0,0 +1,55 @@ +package corecord.dev.domain.analysis.presentation; + +import corecord.dev.common.response.ApiResponse; +import corecord.dev.common.web.UserId; +import corecord.dev.domain.analysis.status.AnalysisSuccessStatus; +import corecord.dev.domain.analysis.domain.dto.request.AnalysisRequest; +import corecord.dev.domain.analysis.domain.dto.response.AnalysisResponse; +import corecord.dev.domain.analysis.application.AnalysisService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/analysis") +public class AnalysisController { + private final AnalysisService analysisService; + + @PostMapping("/{recordId}") + public ResponseEntity> postAnalysis( + @UserId Long userId, + @PathVariable(name = "recordId") Long recordId + ) { + AnalysisResponse.AnalysisDto analysisResponse = analysisService.postAnalysis(userId, recordId); + return ApiResponse.success(AnalysisSuccessStatus.ANALYSIS_POST_SUCCESS, analysisResponse); + } + + @GetMapping("/{analysisId}") + public ResponseEntity> getAnalysis( + @UserId Long userId, + @PathVariable(name = "analysisId") Long analysisId + ) { + AnalysisResponse.AnalysisDto analysisResponse = analysisService.getAnalysis(userId, analysisId); + return ApiResponse.success(AnalysisSuccessStatus.ANALYSIS_GET_SUCCESS, analysisResponse); + } + + @PatchMapping("") + public ResponseEntity> updateAnalysis( + @UserId Long userId, + @RequestBody @Valid AnalysisRequest.AnalysisUpdateDto analysisUpdateDto + ) { + AnalysisResponse.AnalysisDto analysisResponse = analysisService.updateAnalysis(userId, analysisUpdateDto); + return ApiResponse.success(AnalysisSuccessStatus.ANALYSIS_UPDATE_SUCCESS, analysisResponse); + } + + @DeleteMapping("/{analysisId}") + public ResponseEntity> deleteAnalysis( + @UserId Long userId, + @PathVariable(name = "analysisId") Long analysisId + ) { + analysisService.deleteAnalysis(userId, analysisId); + return ApiResponse.success(AnalysisSuccessStatus.ANALYSIS_DELETE_SUCCESS); + } +} diff --git a/src/main/java/corecord/dev/domain/analysis/status/AnalysisErrorStatus.java b/src/main/java/corecord/dev/domain/analysis/status/AnalysisErrorStatus.java new file mode 100644 index 0000000..0108944 --- /dev/null +++ b/src/main/java/corecord/dev/domain/analysis/status/AnalysisErrorStatus.java @@ -0,0 +1,22 @@ +package corecord.dev.domain.analysis.status; + +import corecord.dev.common.base.BaseErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AnalysisErrorStatus implements BaseErrorStatus { + OVERFLOW_ANALYSIS_CONTENT(HttpStatus.BAD_REQUEST, "E0400_OVERFLOW_CONTENT", "경험 기록 내용은 500자 이내여야 합니다."), + OVERFLOW_ANALYSIS_COMMENT(HttpStatus.INTERNAL_SERVER_ERROR, "E0500_OVERFLOW_COMMENT", "경험 기록 코멘트는 200자 이내여야 합니다."), + OVERFLOW_ANALYSIS_KEYWORD_CONTENT(HttpStatus.INTERNAL_SERVER_ERROR, "E0500_OVERFLOW_KEYWORD_CONTENT", "경험 기록 키워드별 내용은 200자 이내여야 합니다."), + USER_ANALYSIS_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "E401_ANALYSIS_UNAUTHORIZED", "유저가 역량 분석에 대한 권한이 없습니다."), + ANALYSIS_NOT_FOUND(HttpStatus.NOT_FOUND, "E0404_ANALYSIS", "존재하지 않는 역량 분석입니다."), + INVALID_ABILITY_ANALYSIS(HttpStatus.INTERNAL_SERVER_ERROR, "E500_INVALID_ANALYSIS", "역량 분석 데이터 파싱 중 오류가 발생했습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/corecord/dev/domain/analysis/status/AnalysisSuccessStatus.java b/src/main/java/corecord/dev/domain/analysis/status/AnalysisSuccessStatus.java new file mode 100644 index 0000000..398de3b --- /dev/null +++ b/src/main/java/corecord/dev/domain/analysis/status/AnalysisSuccessStatus.java @@ -0,0 +1,20 @@ +package corecord.dev.domain.analysis.status; + +import corecord.dev.common.base.BaseSuccessStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AnalysisSuccessStatus implements BaseSuccessStatus { + ANALYSIS_POST_SUCCESS(HttpStatus.CREATED, "S501", "역량별 경험 분석이 성공적으로 완료되었습니다."), + ANALYSIS_GET_SUCCESS(HttpStatus.OK, "S502", "역량별 경험 조회가 성공적으로 완료되었습니다."), + ANALYSIS_UPDATE_SUCCESS(HttpStatus.OK, "S701", "역량별 경험 수정이 성공적으로 완료되었습니다."), + ANALYSIS_DELETE_SUCCESS(HttpStatus.OK, "S702", "역량별 경험 삭제가 성공적으로 완료되었습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/corecord/dev/domain/auth/application/TokenService.java b/src/main/java/corecord/dev/domain/auth/application/TokenService.java new file mode 100644 index 0000000..722b251 --- /dev/null +++ b/src/main/java/corecord/dev/domain/auth/application/TokenService.java @@ -0,0 +1,143 @@ +package corecord.dev.domain.auth.application; + +import corecord.dev.common.exception.GeneralException; +import corecord.dev.common.status.ErrorStatus; +import corecord.dev.common.util.CookieUtil; +import corecord.dev.domain.auth.jwt.JwtUtil; +import corecord.dev.domain.auth.domain.entity.RefreshToken; +import corecord.dev.domain.auth.domain.entity.TmpToken; +import corecord.dev.domain.auth.status.TokenErrorStatus; +import corecord.dev.domain.auth.exception.TokenException; +import corecord.dev.domain.auth.domain.repository.RefreshTokenRepository; +import corecord.dev.domain.auth.domain.repository.TmpTokenRepository; +import corecord.dev.domain.user.domain.converter.UserConverter; +import corecord.dev.domain.user.domain.dto.response.UserResponse; +import corecord.dev.domain.user.domain.entity.User; +import corecord.dev.domain.user.domain.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TokenService { + + private final RefreshTokenRepository refreshTokenRepository; + private final UserRepository userRepository; + private final JwtUtil jwtUtil; + private final CookieUtil cookieUtil; + private final TmpTokenRepository tmpTokenRepository; + + @Value("${jwt.access-token.expiration-time}") + private long accessTokenExpirationTime; + + @Value("${jwt.refresh-token.expiration-time}") + private long refreshTokenExpirationTime; + + /** + * 임시 토큰을 이용하여 AccessToken과 RefreshToken을 발급한다. + * @param response + * @param tmpToken + * @return + */ + @Transactional + public UserResponse.UserDto issueTokens(HttpServletResponse response, String tmpToken) { + // 임시 토큰 유효성 검증 + TmpToken tmpTokenEntity = validateTmpToken(tmpToken); + tmpTokenRepository.delete(tmpTokenEntity); + + // 새 RefreshToken 발급 및 저장 + Long userId = Long.parseLong(jwtUtil.getUserIdFromTmpToken(tmpToken)); + String refreshToken = jwtUtil.generateRefreshToken(userId); + RefreshToken newRefreshToken = RefreshToken.of(refreshToken, userId); + refreshTokenRepository.save(newRefreshToken); + + // 새 AccessToken 발급 + String accessToken = jwtUtil.generateAccessToken(userId); + log.info("AccessToken: {}", accessToken); + + // 쿠키에 AccessToken 및 RefreshToken 추가 + setTokenCookies(response, "refreshToken", refreshToken); + setTokenCookies(response, "accessToken", accessToken); + + User user = findUserById(userId); + return UserConverter.toUserDto(user); + } + + /** + * RefreshToken을 이용하여 새로운 AccessToken을 발급한다. + * @param request + * @param response + * @return + */ + @Transactional + public UserResponse.UserDto reissueAccessToken(HttpServletRequest request, HttpServletResponse response) { + String refreshToken = getRefreshTokenFromCookie(request); + + // RefreshToken 유효성 검증 + validateRefreshToken(refreshToken); + + // 새 AccessToken 발급 + Long userId = Long.parseLong(jwtUtil.getUserIdFromRefreshToken(refreshToken)); + String newAccessToken = jwtUtil.generateAccessToken(userId); + + // 기존 AccessToken 삭제 + ResponseCookie accessTokenCookie = cookieUtil.deleteCookie("accessToken"); + response.addHeader("Set-Cookie", accessTokenCookie.toString()); + + // 쿠키에 새 AccessToken 추가 + setTokenCookies(response, "accessToken", newAccessToken); + log.info("AccessToken: {}", newAccessToken); + + User user = findUserById(userId); + return UserConverter.toUserDto(user); + } + + private String getRefreshTokenFromCookie(HttpServletRequest request) { + String refreshToken = cookieUtil.getCookieValue(request, "refreshToken"); + if (refreshToken == null) { + throw new TokenException(TokenErrorStatus.REFRESH_TOKEN_NOT_FOUND); + } + return refreshToken; + } + + private void validateRefreshToken(String refreshToken) { + if (!jwtUtil.isRefreshTokenValid(refreshToken)) { + throw new TokenException(TokenErrorStatus.INVALID_REFRESH_TOKEN); + } + + refreshTokenRepository.findByRefreshToken(refreshToken) + .orElseThrow(() -> new TokenException(TokenErrorStatus.REFRESH_TOKEN_NOT_FOUND)); + + } + + private TmpToken validateTmpToken(String tmpToken) { + if (!jwtUtil.isTmpTokenValid(tmpToken)) { + throw new TokenException(TokenErrorStatus.INVALID_TMP_TOKEN); + } + return tmpTokenRepository.findByTmpToken(tmpToken) + .orElseThrow(() -> new TokenException(TokenErrorStatus.TMP_TOKEN_NOT_FOUND)); + } + + // 토큰 쿠키 설정 + private void setTokenCookies(HttpServletResponse response, String tokenName, String token) { + if(tokenName.equals("accessToken")) { + ResponseCookie accessTokenCookie = cookieUtil.createTokenCookie(tokenName, token, accessTokenExpirationTime); + response.addHeader("Set-Cookie", accessTokenCookie.toString()); + } else { + ResponseCookie refreshTokenCookie = cookieUtil.createTokenCookie(tokenName, token, refreshTokenExpirationTime); + response.addHeader("Set-Cookie", refreshTokenCookie.toString()); + } + } + + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.UNAUTHORIZED)); + } +} diff --git a/src/main/java/corecord/dev/domain/auth/domain/dto/KakaoUserInfo.java b/src/main/java/corecord/dev/domain/auth/domain/dto/KakaoUserInfo.java new file mode 100644 index 0000000..a21e8a6 --- /dev/null +++ b/src/main/java/corecord/dev/domain/auth/domain/dto/KakaoUserInfo.java @@ -0,0 +1,21 @@ +package corecord.dev.domain.auth.domain.dto; + +import lombok.AllArgsConstructor; + +import java.util.Map; + +@AllArgsConstructor +public class KakaoUserInfo implements OAuth2UserInfo { + + private Map attributes; + + @Override + public String getProviderId() { + return attributes.get("id").toString(); + } + + @Override + public String getName() { + return (String) ((Map) attributes.get("properties")).get("nickname"); + } +} \ No newline at end of file diff --git a/src/main/java/corecord/dev/domain/auth/domain/dto/OAuth2UserInfo.java b/src/main/java/corecord/dev/domain/auth/domain/dto/OAuth2UserInfo.java new file mode 100644 index 0000000..fe473ea --- /dev/null +++ b/src/main/java/corecord/dev/domain/auth/domain/dto/OAuth2UserInfo.java @@ -0,0 +1,6 @@ +package corecord.dev.domain.auth.domain.dto; + +public interface OAuth2UserInfo { + String getProviderId(); + String getName(); +} diff --git a/src/main/java/corecord/dev/domain/auth/domain/entity/RefreshToken.java b/src/main/java/corecord/dev/domain/auth/domain/entity/RefreshToken.java new file mode 100644 index 0000000..21d50ec --- /dev/null +++ b/src/main/java/corecord/dev/domain/auth/domain/entity/RefreshToken.java @@ -0,0 +1,25 @@ +package corecord.dev.domain.auth.domain.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +@Getter +@RedisHash(value = "refreshToken", timeToLive = 604800000) +@AllArgsConstructor +@Builder +public class RefreshToken { + @Id + private String refreshToken; + private Long userId; + + @Builder + public static RefreshToken of(String refreshToken, Long userId) { + return RefreshToken.builder() + .refreshToken(refreshToken) + .userId(userId) + .build(); + } +} diff --git a/src/main/java/corecord/dev/domain/auth/domain/entity/TmpToken.java b/src/main/java/corecord/dev/domain/auth/domain/entity/TmpToken.java new file mode 100644 index 0000000..1f000e8 --- /dev/null +++ b/src/main/java/corecord/dev/domain/auth/domain/entity/TmpToken.java @@ -0,0 +1,25 @@ +package corecord.dev.domain.auth.domain.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +@Getter +@RedisHash(value = "tmpToken", timeToLive = 3600000) +@AllArgsConstructor +@Builder +public class TmpToken { + @Id + private String tmpToken; + private Long userId; + + @Builder + public static TmpToken of(String tmpToken, Long userId) { + return TmpToken.builder() + .tmpToken(tmpToken) + .userId(userId) + .build(); + } +} diff --git a/src/main/java/corecord/dev/domain/auth/domain/repository/RefreshTokenRepository.java b/src/main/java/corecord/dev/domain/auth/domain/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..256a9e4 --- /dev/null +++ b/src/main/java/corecord/dev/domain/auth/domain/repository/RefreshTokenRepository.java @@ -0,0 +1,10 @@ +package corecord.dev.domain.auth.domain.repository; + +import corecord.dev.domain.auth.domain.entity.RefreshToken; +import org.springframework.data.repository.CrudRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends CrudRepository { + Optional findByRefreshToken(String refreshToken); +} diff --git a/src/main/java/corecord/dev/domain/auth/domain/repository/TmpTokenRepository.java b/src/main/java/corecord/dev/domain/auth/domain/repository/TmpTokenRepository.java new file mode 100644 index 0000000..47122a9 --- /dev/null +++ b/src/main/java/corecord/dev/domain/auth/domain/repository/TmpTokenRepository.java @@ -0,0 +1,10 @@ +package corecord.dev.domain.auth.domain.repository; + +import corecord.dev.domain.auth.domain.entity.TmpToken; +import org.springframework.data.repository.CrudRepository; + +import java.util.Optional; + +public interface TmpTokenRepository extends CrudRepository { + Optional findByTmpToken(String tmpToken); +} diff --git a/src/main/java/corecord/dev/domain/auth/exception/TokenException.java b/src/main/java/corecord/dev/domain/auth/exception/TokenException.java new file mode 100644 index 0000000..e9adf12 --- /dev/null +++ b/src/main/java/corecord/dev/domain/auth/exception/TokenException.java @@ -0,0 +1,16 @@ +package corecord.dev.domain.auth.exception; + +import corecord.dev.domain.auth.status.TokenErrorStatus; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class TokenException extends RuntimeException { + private final TokenErrorStatus tokenErrorStatus; + + @Override + public String getMessage() { + return tokenErrorStatus.getMessage(); + } +} diff --git a/src/main/java/corecord/dev/domain/auth/handler/OAuthLoginFailureHandler.java b/src/main/java/corecord/dev/domain/auth/handler/OAuthLoginFailureHandler.java new file mode 100644 index 0000000..f3b0e06 --- /dev/null +++ b/src/main/java/corecord/dev/domain/auth/handler/OAuthLoginFailureHandler.java @@ -0,0 +1,37 @@ +package corecord.dev.domain.auth.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import corecord.dev.common.response.ApiResponse; +import corecord.dev.common.status.ErrorStatus; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuthLoginFailureHandler extends SimpleUrlAuthenticationFailureHandler { + private final ObjectMapper objectMapper; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { + log.error("LOGIN FAILED: {}", exception.getMessage()); + + // 응답 설정 + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + + // 에러 응답 생성 + ApiResponse errorResponse = ApiResponse.error(ErrorStatus.UNAUTHORIZED).getBody(); + + // 응답에 에러 메시지 작성 + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/corecord/dev/domain/auth/handler/OAuthLoginSuccessHandler.java b/src/main/java/corecord/dev/domain/auth/handler/OAuthLoginSuccessHandler.java new file mode 100644 index 0000000..cc324d2 --- /dev/null +++ b/src/main/java/corecord/dev/domain/auth/handler/OAuthLoginSuccessHandler.java @@ -0,0 +1,99 @@ +package corecord.dev.domain.auth.handler; + +import corecord.dev.common.util.CookieUtil; +import corecord.dev.domain.auth.jwt.JwtUtil; +import corecord.dev.domain.auth.domain.dto.KakaoUserInfo; +import corecord.dev.domain.auth.domain.dto.OAuth2UserInfo; +import corecord.dev.domain.auth.domain.entity.RefreshToken; +import corecord.dev.domain.auth.domain.entity.TmpToken; +import corecord.dev.domain.auth.domain.repository.RefreshTokenRepository; +import corecord.dev.domain.auth.domain.repository.TmpTokenRepository; +import corecord.dev.domain.user.domain.entity.User; +import corecord.dev.domain.user.domain.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuthLoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + @Value("${jwt.redirect.access}") + private String ACCESS_TOKEN_REDIRECT_URI; + + @Value("${jwt.redirect.register}") + private String REGISTER_TOKEN_REDIRECT_URI; + + private final JwtUtil jwtUtil; + private final CookieUtil cookieUtil; + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final TmpTokenRepository tmpTokenRepository; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) authentication; + OAuth2UserInfo oAuth2UserInfo = new KakaoUserInfo(token.getPrincipal().getAttributes()); + + String providerId = oAuth2UserInfo.getProviderId(); + String name = oAuth2UserInfo.getName(); + log.info("providerId: {}", providerId); + log.info("name: {}", name); + + Optional optionalUser = userRepository.findByProviderId(providerId); + + if (optionalUser.isPresent()) { + handleExistingUser(request, response, optionalUser.get()); + } else { + handleNewUser(request, response, providerId); + } + } + + // 기존 유저 처리 + private void handleExistingUser(HttpServletRequest request, HttpServletResponse response, User user) throws IOException { + log.info("기존 유저입니다. 임시 토큰을 발급합니다."); + // 기존 리프레쉬 토큰, 쿠키 삭제 + deleteByUserId(user.getUserId()); + ResponseCookie refreshTokenCookie = cookieUtil.deleteCookie("refreshToken"); + response.addHeader("Set-Cookie", refreshTokenCookie.toString()); + + // 액세스 토큰 쿠키 삭제 + ResponseCookie accessTokenCookie = cookieUtil.deleteCookie("accessToken"); + response.addHeader("Set-Cookie", accessTokenCookie.toString()); + + // 임시 토큰 생성 + String tmpToken = jwtUtil.generateTmpToken(user.getUserId()); + tmpTokenRepository.save(TmpToken.of(tmpToken, user.getUserId())); + String redirectURI = String.format(ACCESS_TOKEN_REDIRECT_URI, tmpToken); + + getRedirectStrategy().sendRedirect(request, response, redirectURI); + } + + // 신규 유저 처리 + private void handleNewUser(HttpServletRequest request, HttpServletResponse response, String providerId) throws IOException { + log.info("신규 유저입니다. 레지스터 토큰을 발급합니다."); + // 레지스터 토큰 발급 + String registerToken = jwtUtil.generateRegisterToken(providerId); + String redirectURI = String.format(REGISTER_TOKEN_REDIRECT_URI, registerToken); + getRedirectStrategy().sendRedirect(request, response, redirectURI); + } + + private void deleteByUserId(Long userId) { + Iterable tokens = refreshTokenRepository.findAll(); + for (RefreshToken token : tokens) { + if (token.getUserId().equals(userId)) { + refreshTokenRepository.delete(token); + } + } + } +} diff --git a/src/main/java/corecord/dev/domain/auth/jwt/JwtFilter.java b/src/main/java/corecord/dev/domain/auth/jwt/JwtFilter.java new file mode 100644 index 0000000..33e1497 --- /dev/null +++ b/src/main/java/corecord/dev/domain/auth/jwt/JwtFilter.java @@ -0,0 +1,72 @@ +package corecord.dev.domain.auth.jwt; + +import corecord.dev.common.util.CookieUtil; +import corecord.dev.domain.auth.exception.TokenException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final CookieUtil cookieUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try { + String accessToken = cookieUtil.getCookieValue(request, "accessToken"); + if (accessToken != null && jwtUtil.isAccessTokenValid(accessToken)) { + String userId = jwtUtil.getUserIdFromAccessToken(accessToken); + Authentication authToken = new UsernamePasswordAuthenticationToken( + userId, // principal로 userId 사용 + null, // credentials는 필요 없으므로 null + null // authorities는 비워둠 (필요한 경우 권한 추가) + ); + + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } catch (TokenException e) { + logger.error("JWT Filter Error", e); + handleTokenException(response, e); + return; + } catch (Exception e) { + logger.error("JWT Filter Error", e); + handleException(response, e); + return; + } + filterChain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + // 특정 경로는 필터링하지 않도록 설정 + String path = request.getRequestURI(); + return path.startsWith("/oauth2/authorization/kakao") || path.startsWith("/api/users/register") || path.startsWith("/api/token") || path.startsWith("/actuator/health"); + } + + private void handleTokenException(HttpServletResponse response, TokenException e) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + String jsonResponse = String.format("{\"isSuccess\": \"false\", \"code\": \"%s\", \"message\": \"%s\"}", e.getTokenErrorStatus().getCode(), e.getMessage()); + response.getWriter().write(jsonResponse); + } + + private void handleException(HttpServletResponse response, Exception e) throws IOException { + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + String jsonResponse = String.format("{\"isSuccess\": \"false\", \"code\": \"E9999\", \"message\": \"%s\"}", e.getMessage()); + response.getWriter().write(jsonResponse); + } + +} diff --git a/src/main/java/corecord/dev/domain/auth/jwt/JwtUtil.java b/src/main/java/corecord/dev/domain/auth/jwt/JwtUtil.java new file mode 100644 index 0000000..0143a42 --- /dev/null +++ b/src/main/java/corecord/dev/domain/auth/jwt/JwtUtil.java @@ -0,0 +1,158 @@ +package corecord.dev.domain.auth.jwt; + +import corecord.dev.domain.auth.status.TokenErrorStatus; +import corecord.dev.domain.auth.exception.TokenException; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; + +@Slf4j +@Component +public class JwtUtil { + + @Value("${jwt.secret}") + private String SECRET_KEY; + + @Value("${jwt.register-token.expiration-time}") + private long REGISTER_TOKEN_EXPIRATION_TIME; + + @Value("${jwt.access-token.expiration-time}") + private long ACCESS_TOKEN_EXPIRATION_TIME; + + @Value("${jwt.refresh-token.expiration-time}") + private long REFRESH_TOKEN_EXPIRATION_TIME; + + // SecretKey 생성 + private SecretKey getSigningKey() { + byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY); + return Keys.hmacShaKeyFor(keyBytes); + } + + // JWT 토큰 생성 공통 로직 + private String createToken(String claimKey, String claimValue, long expirationTime) { + return Jwts.builder() + .claim(claimKey, claimValue) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + expirationTime)) + .signWith(getSigningKey()) + .compact(); + } + + // 액세스 토큰 생성 + public String generateAccessToken(Long userId) { + log.info("액세스 토큰이 발행되었습니다."); + return createToken("userId", userId.toString(), ACCESS_TOKEN_EXPIRATION_TIME); + } + + // 리프레쉬 토큰 생성 + public String generateRefreshToken(Long userId) { + log.info("리프레쉬 토큰이 발행되었습니다."); + return createToken("userId", userId.toString(), REFRESH_TOKEN_EXPIRATION_TIME); + } + + // 레지스터 토큰 생성 + public String generateRegisterToken(String providerId) { + log.info("레지스터 토큰이 발행되었습니다."); + return createToken("providerId", providerId, REGISTER_TOKEN_EXPIRATION_TIME); + } + + // 임시 토큰 생성 + public String generateTmpToken(Long userId) { + log.info("임시 토큰이 발행되었습니다."); + return createToken("userId", userId.toString(), 3600000); + } + + // 토큰 검증 공통 로직 + private boolean isTokenValid(String token, String claimKey, TokenErrorStatus errorStatus) { + try { + var claims = Jwts.parser() + .verifyWith(this.getSigningKey()) + .build() + .parseSignedClaims(token); + + if (claims.getPayload().getExpiration().before(new Date())) { + log.warn("{}이 만료되었습니다.", errorStatus.getMessage()); + throw new TokenException(errorStatus); + } + + String claimValue = claims.getPayload().get(claimKey, String.class); + if (claimValue == null || claimValue.isEmpty()) { + log.warn("토큰에 {} 클레임이 없습니다.", claimKey); + throw new TokenException(errorStatus); + } + + return true; + + } catch (ExpiredJwtException e) { + log.warn("토큰이 만료되었습니다: {}", e.getMessage()); + throw new TokenException(errorStatus); + } catch (JwtException | MalformedJwtException e) { + log.warn("유효하지 않은 토큰입니다: {}", e.getMessage()); + throw new TokenException(errorStatus); + } + } + + // 레지스터 토큰 유효성 검증 + public boolean isRegisterTokenValid(String token) { + return isTokenValid(token, "providerId", TokenErrorStatus.INVALID_REGISTER_TOKEN); + } + + // 액세스 토큰 유효성 검증 + public boolean isAccessTokenValid(String token) { + return isTokenValid(token, "userId", TokenErrorStatus.INVALID_ACCESS_TOKEN); + } + + // 리프레쉬 토큰 유효성 검증 + public boolean isRefreshTokenValid(String token) { + return isTokenValid(token, "userId", TokenErrorStatus.INVALID_REFRESH_TOKEN); + } + + // 임시 토큰 유효성 검증 + public boolean isTmpTokenValid(String token) { + return isTokenValid(token, "userId", TokenErrorStatus.INVALID_TMP_TOKEN); + } + + // 토큰에서 클레임 추출 공통 로직 + private String getClaimFromToken(String token, String claimKey, TokenErrorStatus errorStatus) { + try { + return Jwts.parser() + .verifyWith(this.getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload() + .get(claimKey, String.class); + } catch (JwtException | IllegalArgumentException e) { + log.warn("유효하지 않은 토큰입니다."); + throw new TokenException(errorStatus); + } + } + + // 레지스터 토큰에서 providerId 추출 + public String getProviderIdFromToken(String token) { + return getClaimFromToken(token, "providerId", TokenErrorStatus.INVALID_REGISTER_TOKEN); + } + + // 액세스 토큰에서 userId 추출 + public String getUserIdFromAccessToken(String token) { + return getClaimFromToken(token, "userId", TokenErrorStatus.INVALID_ACCESS_TOKEN); + } + + // 리프레쉬 토큰에서 userId 추출 + public String getUserIdFromRefreshToken(String token) { + return getClaimFromToken(token, "userId", TokenErrorStatus.INVALID_REFRESH_TOKEN); + } + + // 임시 토큰에서 userId 추출 + public String getUserIdFromTmpToken(String token) { + return getClaimFromToken(token, "userId", TokenErrorStatus.INVALID_TMP_TOKEN); + } +} diff --git a/src/main/java/corecord/dev/domain/auth/presentation/TokenController.java b/src/main/java/corecord/dev/domain/auth/presentation/TokenController.java new file mode 100644 index 0000000..651d2d9 --- /dev/null +++ b/src/main/java/corecord/dev/domain/auth/presentation/TokenController.java @@ -0,0 +1,36 @@ +package corecord.dev.domain.auth.presentation; + +import corecord.dev.common.response.ApiResponse; +import corecord.dev.domain.auth.status.TokenSuccessStatus; +import corecord.dev.domain.auth.application.TokenService; +import corecord.dev.domain.user.domain.dto.response.UserResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/token") +@RequiredArgsConstructor +public class TokenController { + private final TokenService tokenService; + + @GetMapping("/reissue") + public ResponseEntity> reissueAccessToken( + HttpServletRequest request, + HttpServletResponse response + ) { + tokenService.reissueAccessToken(request, response); + return ApiResponse.success(TokenSuccessStatus.REISSUE_ACCESS_TOKEN_SUCCESS); + } + + @GetMapping + public ResponseEntity> issueToken( + HttpServletResponse response, + @RequestHeader("tmpToken") String tmpToken + ) { + UserResponse.UserDto issueTokenResponse = tokenService.issueTokens(response, tmpToken); + return ApiResponse.success(TokenSuccessStatus.ISSUE_TOKENS_SUCCESS, issueTokenResponse); + } +} diff --git a/src/main/java/corecord/dev/domain/auth/status/TokenErrorStatus.java b/src/main/java/corecord/dev/domain/auth/status/TokenErrorStatus.java new file mode 100644 index 0000000..62ebfaf --- /dev/null +++ b/src/main/java/corecord/dev/domain/auth/status/TokenErrorStatus.java @@ -0,0 +1,22 @@ +package corecord.dev.domain.auth.status; + +import corecord.dev.common.base.BaseErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum TokenErrorStatus implements BaseErrorStatus { + INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "E0400_ACCESS", "유효하지 않은 액세스 토큰입니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "E0400_REFRESH", "유효하지 않은 리프레쉬 토큰입니다."), + INVALID_REGISTER_TOKEN(HttpStatus.UNAUTHORIZED, "E0400_REGISTER", "유효하지 않은 회원가입 토큰입니다."), + INVALID_TMP_TOKEN(HttpStatus.UNAUTHORIZED, "E0400_TMP", "유효하지 않은 임시 토큰입니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "E0404_REFRESH", "해당 유저 ID의 리프레쉬 토큰이 없습니다."), + TMP_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "E0404_TMP", "일치하는 임시 토큰이 없습니다."), + REGISTER_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "E0404_REGISTER", "회원가입 토큰이 없습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/corecord/dev/domain/auth/status/TokenSuccessStatus.java b/src/main/java/corecord/dev/domain/auth/status/TokenSuccessStatus.java new file mode 100644 index 0000000..692a694 --- /dev/null +++ b/src/main/java/corecord/dev/domain/auth/status/TokenSuccessStatus.java @@ -0,0 +1,17 @@ +package corecord.dev.domain.auth.status; + +import corecord.dev.common.base.BaseSuccessStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum TokenSuccessStatus implements BaseSuccessStatus { + ISSUE_TOKENS_SUCCESS(HttpStatus.OK, "S104", "Access Token 및 Refresh Token 발급 성공입니다."), + REISSUE_ACCESS_TOKEN_SUCCESS(HttpStatus.CREATED, "S103", "Access Token 재발급 성공입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/corecord/dev/domain/chat/application/ChatService.java b/src/main/java/corecord/dev/domain/chat/application/ChatService.java new file mode 100644 index 0000000..f8a9d76 --- /dev/null +++ b/src/main/java/corecord/dev/domain/chat/application/ChatService.java @@ -0,0 +1,253 @@ +package corecord.dev.domain.chat.application; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import corecord.dev.common.exception.GeneralException; +import corecord.dev.common.status.ErrorStatus; +import corecord.dev.domain.chat.domain.converter.ChatConverter; +import corecord.dev.domain.chat.domain.dto.request.ChatRequest; +import corecord.dev.domain.chat.domain.dto.response.ChatResponse; +import corecord.dev.domain.chat.infra.clova.dto.response.ChatSummaryAiResponse; +import corecord.dev.domain.chat.domain.entity.Chat; +import corecord.dev.domain.chat.domain.entity.ChatRoom; +import corecord.dev.domain.chat.status.ChatErrorStatus; +import corecord.dev.domain.chat.exception.ChatException; +import corecord.dev.domain.chat.domain.repository.ChatRepository; +import corecord.dev.domain.chat.domain.repository.ChatRoomRepository; +import corecord.dev.domain.chat.infra.clova.dto.request.ClovaRequest; +import corecord.dev.domain.chat.infra.clova.application.ClovaService; +import corecord.dev.domain.user.domain.entity.User; +import corecord.dev.domain.user.domain.repository.UserRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class ChatService { + + private final ChatRoomRepository chatRoomRepository; + private final ChatRepository chatRepository; + private final UserRepository userRepository; + private final ClovaService clovaService; + + /* + * user의 채팅방을 생성하고 생성된 채팅방 정보를 반환 + * @param userId + * @return chatRoomDto + */ + @Transactional + public ChatResponse.ChatRoomDto createChatRoom(Long userId) { + User user = findUserById(userId); + + // 채팅방 생성 + ChatRoom chatRoom = ChatConverter.toChatRoomEntity(user); + chatRoomRepository.save(chatRoom); + + // 첫번째 채팅 생성 - "안녕하세요! {nickName}님! {nickName}님의 경험이 궁금해요. {nickName}님의 경험을 들려주세요!" + Chat firstChat = createFirstChat(user, chatRoom); + + return ChatConverter.toChatRoomDto(chatRoom, firstChat); + } + + /* + * user의 채팅방에 채팅을 생성하고 생성된 채팅 정보와 AI 답변 반환 + * @param chatRoomId + * @param chatDto + * @return + */ + @Transactional + public ChatResponse.ChatsDto createChat(Long userId, Long chatRoomId, ChatRequest.ChatDto chatDto) { + User user = findUserById(userId); + ChatRoom chatRoom = findChatRoomById(chatRoomId, user); + + // 사용자 채팅 생성 + Chat chat = ChatConverter.toChatEntity(1, chatDto.getContent(), chatRoom); + chatRepository.save(chat); + + // 가이드이면 가이드 채팅 생성 + if(chatDto.isGuide()) { + checkGuideChat(chatRoom); + return generateGuideChats(chatRoom); + } + + // AI 답변 생성 + String aiAnswer = createChatAiAnswer(chatRoom, chatDto.getContent()); + Chat aiChat = chatRepository.save(ChatConverter.toChatEntity(0, aiAnswer, chatRoom)); + + return ChatConverter.toChatsDto(List.of(aiChat)); + } + + /* + * user의 채팅방의 채팅 목록을 반환 + * @param userId + * @param chatRoomId + * @return chatListDto + */ + public ChatResponse.ChatListDto getChatList(Long userId, Long chatRoomId) { + User user = findUserById(userId); + ChatRoom chatRoom = findChatRoomById(chatRoomId, user); + List chatList = chatRepository.findByChatRoomOrderByChatId(chatRoom); + + return ChatConverter.toChatListDto(chatList); + } + + /* + * user의 채팅방을 삭제 + * @param userId + * @param chatRoomId + */ + @Transactional + public void deleteChatRoom(Long userId, Long chatRoomId) { + User user = findUserById(userId); + ChatRoom chatRoom = findChatRoomById(chatRoomId, user); + + // 임시 저장된 ChatRoom 인지 확인 후 삭제 + checkTmpChat(user, chatRoom); + chatRepository.deleteByChatRoomId(chatRoomId); + chatRoomRepository.delete(chatRoom); + } + + /* + * user의 채팅의 요약 정보를 반환 + * @param userId + * @param chatRoomId + * @return chatSummaryDto + */ + public ChatResponse.ChatSummaryDto getChatSummary(Long userId, Long chatRoomId) { + User user = findUserById(userId); + ChatRoom chatRoom = findChatRoomById(chatRoomId, user); + List chatList = chatRepository.findByChatRoomOrderByChatId(chatRoom); + + // 사용자 입력 없이 저장하려는 경우 체크 + validateChatList(chatList); + + // 채팅 정보 요약 생성 + ChatSummaryAiResponse response = generateChatSummary(chatList); + + validateResponse(response); + + return ChatConverter.toChatSummaryDto(chatRoom, response); + } + + /* + * user의 임시 채팅방과 유무를 반환 + * @param userId + * @return chatTmpDto + */ + @Transactional + public ChatResponse.ChatTmpDto getChatTmp(Long userId) { + User user = findUserById(userId); + if(user.getTmpChat() == null) { + return ChatConverter.toNotExistingChatTmpDto(); + } + // 임시 채팅 제거 후 반환 + Long chatRoomId = user.getTmpChat(); + user.deleteTmpChat(); + return ChatConverter.toExistingChatTmpDto(chatRoomId); + } + + /* + * user의 채팅방을 임시 저장 + * @param userId + * @param chatRoomId + */ + @Transactional + public void saveChatTmp(Long userId, Long chatRoomId) { + User user = findUserById(userId); + ChatRoom chatRoom = findChatRoomById(chatRoomId, user); + + // 이미 임시 저장된 채팅방이 있는 경우 + if(user.getTmpChat() != null) { + throw new ChatException(ChatErrorStatus.TMP_CHAT_EXIST); + } + user.updateTmpChat(chatRoom.getChatRoomId()); + } + + private static void checkGuideChat(ChatRoom chatRoom) { + if(chatRoom.getChatList().size() > 2) { + throw new ChatException(ChatErrorStatus.INVALID_GUIDE_CHAT); + } + } + + private ChatResponse.ChatsDto generateGuideChats(ChatRoom chatRoom) { + Chat guideChat1 = ChatConverter.toChatEntity(0, "걱정 마세요!\n저와 대화하다 보면 경험이 정리될 거예요\uD83D\uDCDD", chatRoom); + Chat guideChat2 = ChatConverter.toChatEntity(0, "오늘은 어떤 경험을 했나요?\n상황과 해결한 문제를 말해주세요!", chatRoom); + chatRepository.save(guideChat1); + chatRepository.save(guideChat2); + return ChatConverter.toChatsDto(List.of(guideChat1, guideChat2)); + } + + private static void validateResponse(ChatSummaryAiResponse response) { + if (response.getTitle().equals("NO_RECORD") || response.getContent().equals("NO_RECORD") || response.getContent().equals("") || response.getTitle().equals("")) { + throw new ChatException(ChatErrorStatus.NO_RECORD); + } + + if (response.getTitle().length() > 30) { + throw new ChatException(ChatErrorStatus.OVERFLOW_SUMMARY_TITLE); + } + + if (response.getContent().length() > 500) { + throw new ChatException(ChatErrorStatus.OVERFLOW_SUMMARY_CONTENT); + } + } + + private static void validateChatList(List chatList) { + if(chatList.size() <= 1) { + throw new ChatException(ChatErrorStatus.NO_RECORD); + } + } + + private ChatSummaryAiResponse generateChatSummary(List chatList) { + ClovaRequest clovaRequest = ClovaRequest.createChatSummaryRequest(chatList); + String response = clovaService.generateAiResponse(clovaRequest); + return parseChatSummaryResponse(response); + } + + private ChatSummaryAiResponse parseChatSummaryResponse(String aiResponse) { + ObjectMapper objectMapper = new ObjectMapper(); + try { + return objectMapper.readValue(aiResponse, ChatSummaryAiResponse.class); + } catch (JsonProcessingException e) { + throw new ChatException(ChatErrorStatus.INVALID_CHAT_SUMMARY); + } + } + + private void checkTmpChat(User user, ChatRoom chatRoom) { + if(user.getTmpChat() == null) { + return; + } + if(user.getTmpChat().equals(chatRoom.getChatRoomId())) { + user.deleteTmpChat(); + } + } + + private String createChatAiAnswer(ChatRoom chatRoom, String userInput) { + List chatHistory = chatRepository.findByChatRoomOrderByChatId(chatRoom); + ClovaRequest clovaRequest = ClovaRequest.createChatRequest(chatHistory, userInput); + return clovaService.generateAiResponse(clovaRequest); + } + + private ChatRoom findChatRoomById(Long chatRoomId, User user) { + return chatRoomRepository.findByChatRoomIdAndUser(chatRoomId, user) + .orElseThrow(() -> new ChatException(ChatErrorStatus.CHAT_ROOM_NOT_FOUND)); + } + + private Chat createFirstChat(User user, ChatRoom chatRoom) { + String nickName = user.getNickName(); + String firstChatContent = String.format("안녕하세요! %s님\n오늘은 어떤 경험을 했나요?\n저와 함께 정리해보아요!", nickName); + Chat chat = ChatConverter.toChatEntity(0, firstChatContent, chatRoom); + chatRepository.save(chat); + return chat; + } + + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.UNAUTHORIZED)); + } + +} diff --git a/src/main/java/corecord/dev/domain/chat/domain/converter/ChatConverter.java b/src/main/java/corecord/dev/domain/chat/domain/converter/ChatConverter.java new file mode 100644 index 0000000..4193680 --- /dev/null +++ b/src/main/java/corecord/dev/domain/chat/domain/converter/ChatConverter.java @@ -0,0 +1,84 @@ +package corecord.dev.domain.chat.domain.converter; + +import corecord.dev.domain.chat.infra.clova.dto.response.ChatSummaryAiResponse; +import corecord.dev.domain.chat.domain.dto.response.ChatResponse; +import corecord.dev.domain.chat.domain.entity.Chat; +import corecord.dev.domain.chat.domain.entity.ChatRoom; +import corecord.dev.domain.user.domain.entity.User; + +import java.time.format.DateTimeFormatter; +import java.util.List; + +public class ChatConverter { + + public static ChatResponse.ChatRoomDto toChatRoomDto(ChatRoom chatRoom, Chat firstChat) { + return ChatResponse.ChatRoomDto.builder() + .chatRoomId(chatRoom.getChatRoomId()) + .firstChat(firstChat.getContent()) + .build(); + } + + public static ChatRoom toChatRoomEntity(User user) { + return ChatRoom.builder() + .user(user) + .build(); + } + + public static Chat toChatEntity(Integer author, String content, ChatRoom chatRoom) { + return Chat.builder() + .author(author) + .content(content) + .chatRoom(chatRoom) + .build(); + } + + public static ChatResponse.ChatDto toChatDto(Chat chat) { + return ChatResponse.ChatDto.builder() + .chatId(chat.getChatId()) + .content(chat.getContent()) + .build(); + } + + public static ChatResponse.ChatsDto toChatsDto(List chats) { + return ChatResponse.ChatsDto.builder() + .chats(chats.stream().map(ChatConverter::toChatDto).toList()) + .build(); + } + + public static ChatResponse.ChatDetailDto toChatDetailDto(Chat chat) { + return ChatResponse.ChatDetailDto.builder() + .chatId(chat.getChatId()) + .author(chat.getAuthor() == 0 ? "ai" : "user") + .content(chat.getContent()) + .created_at(chat.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .build(); + } + + public static ChatResponse.ChatListDto toChatListDto(List chatList) { + return ChatResponse.ChatListDto.builder() + .chats(chatList.stream().map(ChatConverter::toChatDetailDto).toList()) + .build(); + } + + public static ChatResponse.ChatSummaryDto toChatSummaryDto(ChatRoom chatRoom, ChatSummaryAiResponse response) { + return ChatResponse.ChatSummaryDto.builder() + .chatRoomId(chatRoom.getChatRoomId()) + .title(response.getTitle()) + .content(response.getContent()) + .build(); + } + + public static ChatResponse.ChatTmpDto toExistingChatTmpDto(Long chatRoomId) { + return ChatResponse.ChatTmpDto.builder() + .isExist(true) + .chatRoomId(chatRoomId) + .build(); + } + + public static ChatResponse.ChatTmpDto toNotExistingChatTmpDto() { + return ChatResponse.ChatTmpDto.builder() + .isExist(false) + .chatRoomId(null) + .build(); + } +} diff --git a/src/main/java/corecord/dev/domain/chat/domain/dto/request/ChatRequest.java b/src/main/java/corecord/dev/domain/chat/domain/dto/request/ChatRequest.java new file mode 100644 index 0000000..313ab6b --- /dev/null +++ b/src/main/java/corecord/dev/domain/chat/domain/dto/request/ChatRequest.java @@ -0,0 +1,16 @@ +package corecord.dev.domain.chat.domain.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; +import lombok.Getter; + +public class ChatRequest { + + @Getter + @Builder + public static class ChatDto { + private boolean guide; + @NotBlank(message = "채팅을 입력해주세요.") + private String content; + } +} diff --git a/src/main/java/corecord/dev/domain/chat/domain/dto/response/ChatResponse.java b/src/main/java/corecord/dev/domain/chat/domain/dto/response/ChatResponse.java new file mode 100644 index 0000000..d291e71 --- /dev/null +++ b/src/main/java/corecord/dev/domain/chat/domain/dto/response/ChatResponse.java @@ -0,0 +1,76 @@ +package corecord.dev.domain.chat.domain.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Getter; + +import java.util.List; + +public class ChatResponse { + + @Builder + @Getter + @AllArgsConstructor + @Data + public static class ChatRoomDto { + private Long chatRoomId; + private String firstChat; + } + + @Builder + @Getter + @AllArgsConstructor + @Data + public static class ChatDto { + private Long chatId; + private String content; + } + + @Builder + @Getter + @AllArgsConstructor + @Data + public static class ChatListDto { + private List chats; + } + + @Builder + @Getter + @AllArgsConstructor + @Data + public static class ChatDetailDto { + private Long chatId; + private String author; + private String content; + private String created_at; + } + + @Builder + @Getter + @AllArgsConstructor + @Data + public static class ChatSummaryDto { + private Long chatRoomId; + private String title; + private String content; + } + + @Builder + @Getter + @AllArgsConstructor + @Data + public static class ChatTmpDto { + private Long chatRoomId; + private boolean isExist; + } + + @Builder + @Getter + @AllArgsConstructor + @Data + public static class ChatsDto { + private List chats; + } + +} diff --git a/src/main/java/corecord/dev/domain/chat/domain/entity/Chat.java b/src/main/java/corecord/dev/domain/chat/domain/entity/Chat.java new file mode 100644 index 0000000..6eb73ed --- /dev/null +++ b/src/main/java/corecord/dev/domain/chat/domain/entity/Chat.java @@ -0,0 +1,31 @@ +package corecord.dev.domain.chat.domain.entity; + +import corecord.dev.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Chat extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(nullable = false) + private Long chatId; + + @Column(nullable = false) + private Integer author; // 0(user), 1(ai) + + @Column(nullable = false, length = 500) + private String content; + + @ManyToOne + @JoinColumn(name = "chat_room_id", nullable = false) + private ChatRoom chatRoom; +} diff --git a/src/main/java/corecord/dev/domain/chat/domain/entity/ChatRoom.java b/src/main/java/corecord/dev/domain/chat/domain/entity/ChatRoom.java new file mode 100644 index 0000000..e341b5b --- /dev/null +++ b/src/main/java/corecord/dev/domain/chat/domain/entity/ChatRoom.java @@ -0,0 +1,35 @@ +package corecord.dev.domain.chat.domain.entity; + +import corecord.dev.common.base.BaseEntity; +import corecord.dev.domain.record.domain.entity.Record; +import corecord.dev.domain.user.domain.entity.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ChatRoom extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(nullable = false) + private Long chatRoomId; + + @OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List chatList; + + @OneToOne(mappedBy = "chatRoom") + private Record record; + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; +} diff --git a/src/main/java/corecord/dev/domain/chat/domain/repository/ChatRepository.java b/src/main/java/corecord/dev/domain/chat/domain/repository/ChatRepository.java new file mode 100644 index 0000000..49ef5ea --- /dev/null +++ b/src/main/java/corecord/dev/domain/chat/domain/repository/ChatRepository.java @@ -0,0 +1,19 @@ +package corecord.dev.domain.chat.domain.repository; + +import corecord.dev.domain.chat.domain.entity.Chat; +import corecord.dev.domain.chat.domain.entity.ChatRoom; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ChatRepository extends JpaRepository { + List findByChatRoomOrderByChatId(ChatRoom chatRoom); + + @Modifying + @Query("DELETE FROM Chat c WHERE c.chatRoom.chatRoomId = :chatRoomId") + void deleteByChatRoomId(Long chatRoomId); +} diff --git a/src/main/java/corecord/dev/domain/chat/domain/repository/ChatRoomRepository.java b/src/main/java/corecord/dev/domain/chat/domain/repository/ChatRoomRepository.java new file mode 100644 index 0000000..6b8b741 --- /dev/null +++ b/src/main/java/corecord/dev/domain/chat/domain/repository/ChatRoomRepository.java @@ -0,0 +1,15 @@ +package corecord.dev.domain.chat.domain.repository; + +import corecord.dev.domain.chat.domain.entity.ChatRoom; +import corecord.dev.domain.user.domain.entity.User; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ChatRoomRepository extends JpaRepository { + @EntityGraph(attributePaths = {"record", "user"}) + Optional findByChatRoomIdAndUser(Long chatRoomId, User user); +} diff --git a/src/main/java/corecord/dev/domain/chat/exception/ChatException.java b/src/main/java/corecord/dev/domain/chat/exception/ChatException.java new file mode 100644 index 0000000..058dced --- /dev/null +++ b/src/main/java/corecord/dev/domain/chat/exception/ChatException.java @@ -0,0 +1,16 @@ +package corecord.dev.domain.chat.exception; + +import corecord.dev.domain.chat.status.ChatErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ChatException extends RuntimeException { + private final ChatErrorStatus chatErrorStatus; + + @Override + public String getMessage() { + return chatErrorStatus.getMessage(); + } +} diff --git a/src/main/java/corecord/dev/domain/chat/infra/clova/application/ClovaService.java b/src/main/java/corecord/dev/domain/chat/infra/clova/application/ClovaService.java new file mode 100644 index 0000000..ad165de --- /dev/null +++ b/src/main/java/corecord/dev/domain/chat/infra/clova/application/ClovaService.java @@ -0,0 +1,77 @@ +package corecord.dev.domain.chat.infra.clova.application; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import corecord.dev.common.exception.GeneralException; +import corecord.dev.common.status.ErrorStatus; +import corecord.dev.domain.chat.infra.clova.dto.request.ClovaRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientException; + +@Service +@Slf4j +@RequiredArgsConstructor +public class ClovaService { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final WebClient webClient = WebClient.create(); + + @Value("${ncp.chat.host}") + private String chatHost; + + @Value("${ncp.chat.api-key}") + private String chatApiKey; + + @Value("${ncp.chat.api-key-primary-val}") + private String chatApiKeyPrimaryVal; + + @Value("${ncp.chat.request-id}") + private String chatRequestId; + + public String generateAiResponse(ClovaRequest clovaRequest) { + try { + String responseBody = webClient.post() + .uri(chatHost) + .header("X-NCP-CLOVASTUDIO-API-KEY", chatApiKey) + .header("X-NCP-APIGW-API-KEY", chatApiKeyPrimaryVal) + .header("X-NCP-CLOVASTUDIO-REQUEST-ID", chatRequestId) + .header("Content-Type", "application/json; charset=utf-8") + .header("Accept", "application/json") + .bodyValue(clovaRequest) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, clientResponse -> { + log.error("클라이언트 오류 발생: 상태 코드 - {}", clientResponse.statusCode()); + return clientResponse.bodyToMono(String.class) + .map(errorBody -> new GeneralException(ErrorStatus.AI_CLIENT_ERROR)); + }) + .onStatus(HttpStatusCode::is5xxServerError, clientResponse -> { + log.error("서버 오류 발생: 상태 코드 - {}", clientResponse.statusCode()); + return clientResponse.bodyToMono(String.class) + .map(errorBody -> new GeneralException(ErrorStatus.AI_SERVER_ERROR)); + }) + .bodyToMono(String.class) + .block(); + + return parseContentFromResponse(responseBody); + } catch (WebClientException e) { + log.error("채팅 AI 응답 생성 실패", e); + throw new GeneralException(ErrorStatus.AI_RESPONSE_ERROR); + } + } + + private String parseContentFromResponse(String responseBody) { + try { + JsonNode root = objectMapper.readTree(responseBody); + JsonNode messageContent = root.path("result").path("message").path("content"); + return messageContent.asText(); + } catch (Exception e) { + log.error("응답 파싱 실패", e); + throw new GeneralException(ErrorStatus.AI_RESPONSE_ERROR); + } + } +} diff --git a/src/main/java/corecord/dev/domain/chat/infra/clova/dto/request/ClovaRequest.java b/src/main/java/corecord/dev/domain/chat/infra/clova/dto/request/ClovaRequest.java new file mode 100644 index 0000000..b0a851e --- /dev/null +++ b/src/main/java/corecord/dev/domain/chat/infra/clova/dto/request/ClovaRequest.java @@ -0,0 +1,79 @@ +package corecord.dev.domain.chat.infra.clova.dto.request; + +import corecord.dev.common.util.ResourceLoader; +import corecord.dev.domain.chat.domain.entity.Chat; +import lombok.Getter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Getter +public class ClovaRequest { + private static final int CHAT_MAX_TOKENS = 256; + private static final int SUMMARY_MAX_TOKENS = 500; + private static final int ABILITY_ANALYSIS_MAX_TOKENS = 500; + private static final String CHAT_SYSTEM_CONTENT = ResourceLoader.getResourceContent("chat-prompt.txt"); + private static final String SUMMARY_SYSTEM_CONTENT = ResourceLoader.getResourceContent("chat-summary-prompt.txt"); + + private List> messages; + private final double topP = 0.8; + private final int topK = 0; + private final int maxTokens; + private final double temperature = 0.5; + private final double repeatPenalty = 5.0; + private final boolean includeAiFilters = true; + private final int seed = 0; + + public ClovaRequest(List> messages, int max_tokens) { + this.messages = messages; + this.maxTokens = max_tokens; + } + + public static ClovaRequest createChatRequest(List chatHistory, String userContent) { + List> messages = new ArrayList<>(); + + // 시스템 메시지 추가 + messages.add(Map.of( + "role", "system", + "content", CHAT_SYSTEM_CONTENT + )); + + // 기존 채팅 내역 추가 + for (Chat chat : chatHistory) { + String role = chat.getAuthor() == 0 ? "assistant" : "user"; + messages.add(Map.of("role", role, "content", chat.getContent())); + } + + // 사용자 입력 추가 + messages.add(Map.of("role", "user", "content", userContent)); + + return new ClovaRequest(messages, CHAT_MAX_TOKENS); + } + + public static ClovaRequest createChatSummaryRequest(List chatHistory) { + List> messages = new ArrayList<>(); + + // 시스템 메시지 추가 + messages.add(Map.of( + "role", "system", + "content", SUMMARY_SYSTEM_CONTENT + )); + + // 기존 채팅 내역을 하나의 문자열로 병합 + StringBuilder chatContentBuilder = new StringBuilder(); + for (Chat chat : chatHistory) { + String role = chat.getAuthor() == 0 ? "ai" : "recorder"; + chatContentBuilder.append(role).append(": ").append(chat.getContent()).append("\n"); + } + String chatContent = chatContentBuilder.toString(); + + // 병합된 내용을 추가 + messages.add(Map.of( + "role", "user", + "content", chatContent + )); + + return new ClovaRequest(messages, SUMMARY_MAX_TOKENS); + } +} diff --git a/src/main/java/corecord/dev/domain/chat/infra/clova/dto/response/ChatSummaryAiResponse.java b/src/main/java/corecord/dev/domain/chat/infra/clova/dto/response/ChatSummaryAiResponse.java new file mode 100644 index 0000000..1adc230 --- /dev/null +++ b/src/main/java/corecord/dev/domain/chat/infra/clova/dto/response/ChatSummaryAiResponse.java @@ -0,0 +1,16 @@ +package corecord.dev.domain.chat.infra.clova.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Data +public class ChatSummaryAiResponse { + @JsonProperty("title") + private String title; + @JsonProperty("content") + private String content; +} diff --git a/src/main/java/corecord/dev/domain/chat/presentation/ChatController.java b/src/main/java/corecord/dev/domain/chat/presentation/ChatController.java new file mode 100644 index 0000000..ef6448f --- /dev/null +++ b/src/main/java/corecord/dev/domain/chat/presentation/ChatController.java @@ -0,0 +1,83 @@ +package corecord.dev.domain.chat.presentation; + +import corecord.dev.common.response.ApiResponse; +import corecord.dev.common.web.UserId; +import corecord.dev.domain.chat.status.ChatSuccessStatus; +import corecord.dev.domain.chat.domain.dto.request.ChatRequest; +import corecord.dev.domain.chat.domain.dto.response.ChatResponse; +import corecord.dev.domain.chat.application.ChatService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import static corecord.dev.domain.chat.status.ChatSuccessStatus.CHAT_ROOM_CREATE_SUCCESS; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/records/chat") +public class ChatController { + private final ChatService chatService; + + @PostMapping + public ResponseEntity> createChatRoom( + @UserId Long userId + ) { + ChatResponse.ChatRoomDto chatRoomDto = chatService.createChatRoom(userId); + return ApiResponse.success(CHAT_ROOM_CREATE_SUCCESS, chatRoomDto); + } + + @PostMapping("/{chatRoomId}") + public ResponseEntity> createChat( + @UserId Long userId, + @PathVariable(name = "chatRoomId") Long chatRoomId, + @RequestBody @Valid ChatRequest.ChatDto chatDto + ) { + ChatResponse.ChatsDto chatResponse = chatService.createChat(userId, chatRoomId, chatDto); + return ApiResponse.success(ChatSuccessStatus.CHAT_CREATE_SUCCESS, chatResponse); + } + + @GetMapping("/{chatRoomId}") + public ResponseEntity> getChatList( + @UserId Long userId, + @PathVariable(name = "chatRoomId") Long chatRoomId + ) { + ChatResponse.ChatListDto chatListDto = chatService.getChatList(userId, chatRoomId); + return ApiResponse.success(ChatSuccessStatus.GET_CHAT_SUCCESS, chatListDto); + } + + @DeleteMapping("/{chatRoomId}") + public ResponseEntity> deleteChatRoom( + @UserId Long userId, + @PathVariable(name = "chatRoomId") Long chatRoomId + ) { + chatService.deleteChatRoom(userId, chatRoomId); + return ApiResponse.success(ChatSuccessStatus.CHAT_DELETE_SUCCESS); + } + + @GetMapping("/{chatRoomId}/summary") + public ResponseEntity> getChatSummary( + @UserId Long userId, + @PathVariable(name = "chatRoomId") Long chatRoomId + ) { + ChatResponse.ChatSummaryDto chatSummaryDto = chatService.getChatSummary(userId, chatRoomId); + return ApiResponse.success(ChatSuccessStatus.GET_CHAT_SUMMARY_SUCCESS, chatSummaryDto); + } + + @GetMapping("/tmp") + public ResponseEntity> getChatTmp( + @UserId Long userId + ) { + ChatResponse.ChatTmpDto chatTmpDto = chatService.getChatTmp(userId); + return ApiResponse.success(ChatSuccessStatus.GET_CHAT_TMP_SUCCESS, chatTmpDto); + } + + @PostMapping("/{chatRoomId}/tmp") + public ResponseEntity> saveChatTmp( + @UserId Long userId, + @PathVariable(name = "chatRoomId") Long chatRoomId + ) { + chatService.saveChatTmp(userId, chatRoomId); + return ApiResponse.success(ChatSuccessStatus.CREATE_CHAT_TMP_SUCCESS); + } +} diff --git a/src/main/java/corecord/dev/domain/chat/status/ChatErrorStatus.java b/src/main/java/corecord/dev/domain/chat/status/ChatErrorStatus.java new file mode 100644 index 0000000..f68f4d2 --- /dev/null +++ b/src/main/java/corecord/dev/domain/chat/status/ChatErrorStatus.java @@ -0,0 +1,22 @@ +package corecord.dev.domain.chat.status; + +import corecord.dev.common.base.BaseErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ChatErrorStatus implements BaseErrorStatus { + CHAT_ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "E0302_CHAT_ROOM_NOT_FOUND", "존재하지 않는 채팅방입니다."), + INVALID_GUIDE_CHAT(HttpStatus.BAD_REQUEST, "E0302_INVALID_GUIDE_CHAT", "가이드 채팅은 처음에만 할 수 있습니다."), + OVERFLOW_SUMMARY_TITLE(HttpStatus.BAD_REQUEST, "E0305_OVERFLOW_SUMMARY_TITLE", "경험 제목은 30자 이내여야 합니다."), + OVERFLOW_SUMMARY_CONTENT(HttpStatus.BAD_REQUEST, "E0305_OVERFLOW_SUMMARY_CONTENT", "경험 요약 내용은 500자 이내여야 합니다."), + INVALID_CHAT_SUMMARY(HttpStatus.BAD_REQUEST, "E0305_INVALID_CHAT_SUMMARY", "채팅 경험 요약 파싱 중 오류가 발생했습니다."), + NO_RECORD(HttpStatus.BAD_REQUEST, "E0305_NO_RECORD", "경험 기록의 내용이 충분하지 않습니다."), + TMP_CHAT_EXIST(HttpStatus.BAD_REQUEST, "E0307_TMP_CHAT_EXIST", "임시 채팅이 이미 존재합니다."),; + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/corecord/dev/domain/chat/status/ChatSuccessStatus.java b/src/main/java/corecord/dev/domain/chat/status/ChatSuccessStatus.java new file mode 100644 index 0000000..ab3f993 --- /dev/null +++ b/src/main/java/corecord/dev/domain/chat/status/ChatSuccessStatus.java @@ -0,0 +1,23 @@ +package corecord.dev.domain.chat.status; + +import corecord.dev.common.base.BaseSuccessStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ChatSuccessStatus implements BaseSuccessStatus { + + CHAT_ROOM_CREATE_SUCCESS(HttpStatus.CREATED, "S301", "채팅방 생성이 성공적으로 완료되었습니다."), + CHAT_CREATE_SUCCESS(HttpStatus.CREATED, "S302", "채팅 생성이 성공적으로 완료되었습니다."), + GET_CHAT_SUCCESS(HttpStatus.OK, "S303", "채팅 조회가 성공적으로 완료되었습니다."), + CHAT_DELETE_SUCCESS(HttpStatus.OK, "S304", "채팅 삭제가 성공적으로 완료되었습니다."), + GET_CHAT_SUMMARY_SUCCESS(HttpStatus.OK, "S305", "채팅 요약 생성이 성공적으로 완료되었습니다."), + GET_CHAT_TMP_SUCCESS(HttpStatus.OK, "S306", "임시 채팅 조회가 성공적으로 완료되었습니다."), + CREATE_CHAT_TMP_SUCCESS(HttpStatus.CREATED, "S307", "채팅 임시 저장이 성공적으로 완료되었습니다."),; + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/corecord/dev/domain/folder/application/FolderService.java b/src/main/java/corecord/dev/domain/folder/application/FolderService.java new file mode 100644 index 0000000..dcd2f71 --- /dev/null +++ b/src/main/java/corecord/dev/domain/folder/application/FolderService.java @@ -0,0 +1,131 @@ +package corecord.dev.domain.folder.application; + +import corecord.dev.common.exception.GeneralException; +import corecord.dev.common.status.ErrorStatus; +import corecord.dev.domain.folder.domain.converter.FolderConverter; +import corecord.dev.domain.folder.domain.dto.request.FolderRequest; +import corecord.dev.domain.folder.domain.dto.response.FolderResponse; +import corecord.dev.domain.folder.domain.entity.Folder; +import corecord.dev.domain.folder.status.FolderErrorStatus; +import corecord.dev.domain.folder.exception.FolderException; +import corecord.dev.domain.folder.domain.repository.FolderRepository; +import corecord.dev.domain.user.domain.entity.User; +import corecord.dev.domain.user.domain.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class FolderService { + private final FolderRepository folderRepository; + private final UserRepository userRepository; + + /* + * 폴더명(title)을 request로 받아, 새로운 폴더를 생성 후 생성 순 풀더 리스트 반환 + * @param userId, folderDto + * @return + */ + @Transactional + public FolderResponse.FolderDtoList createFolder(Long userId, FolderRequest.FolderDto folderDto) { + User user = findUserById(userId); + String title = folderDto.getTitle(); + + // 폴더명 유효성 검증 + validDuplicatedFolderTitleAndLength(title); + + // folder 객체 생성 및 User 연관관계 설정 + Folder folder = FolderConverter.toFolderEntity(title, user); + folderRepository.save(folder); + + List folderList = folderRepository.findFolderDtoList(user); + return FolderConverter.toFolderDtoList(folderList); + } + + /* + * folderId를 받아 folder를 삭제한 후 생성 순 폴더 리스트 반환 + * @param userId, folderId + * @return + */ + @Transactional + public FolderResponse.FolderDtoList deleteFolder(Long userId, Long folderId) { + User user = findUserById(userId); + Folder folder = findFolderById(folderId); + + // User-Folder 권한 유효성 검증 + validIsUserAuthorizedForFolder(user, folder); + + folderRepository.delete(folder); + + List folderList = folderRepository.findFolderDtoList(user); + return FolderConverter.toFolderDtoList(folderList); + } + + /* + * folderId를 받아, 해당 folder의 title을 수정 + * @param userId, folderDto + * @return + */ + @Transactional + public FolderResponse.FolderDtoList updateFolder(Long userId, FolderRequest.FolderUpdateDto folderDto) { + User user = findUserById(userId); + Folder folder = findFolderById(folderDto.getFolderId()); + String title = folderDto.getTitle(); + + // 폴더명 유효성 검증 + validDuplicatedFolderTitleAndLength(title); + + // User-Folder 권한 유효성 검증 + validIsUserAuthorizedForFolder(user, folder); + + folder.updateTitle(title); + + List folderList = folderRepository.findFolderDtoList(user); + return FolderConverter.toFolderDtoList(folderList); + } + + /* + * 생성일 오름차순으로 폴더 리스트를 조회 + * @param userId + * @return + */ + @Transactional(readOnly = true) + public FolderResponse.FolderDtoList getFolderList(Long userId) { + User user = findUserById(userId); + + List folderList = folderRepository.findFolderDtoList(user); + return FolderConverter.toFolderDtoList(folderList); + } + + private void validDuplicatedFolderTitleAndLength(String title) { + // 폴더명 글자 수 검사 + if (title.length() > 15) { + throw new FolderException(FolderErrorStatus.OVERFLOW_FOLDER_TITLE); + } + + // 폴더명 중복 검사 + if (folderRepository.existsByTitle(title)) { + throw new FolderException(FolderErrorStatus.DUPLICATED_FOLDER_TITLE); + } + } + + // user-folder 권한 검사 + private void validIsUserAuthorizedForFolder(User user, Folder folder) { + if (!folder.getUser().equals(user)) + throw new FolderException(FolderErrorStatus.USER_FOLDER_UNAUTHORIZED); + } + + private Folder findFolderById(Long folderId) { + return folderRepository.findById(folderId) + .orElseThrow(() -> new FolderException(FolderErrorStatus.FOLDER_NOT_FOUND)); + } + + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.UNAUTHORIZED)); + } +} diff --git a/src/main/java/corecord/dev/domain/folder/domain/converter/FolderConverter.java b/src/main/java/corecord/dev/domain/folder/domain/converter/FolderConverter.java new file mode 100644 index 0000000..7cdac5a --- /dev/null +++ b/src/main/java/corecord/dev/domain/folder/domain/converter/FolderConverter.java @@ -0,0 +1,30 @@ +package corecord.dev.domain.folder.domain.converter; + +import corecord.dev.domain.folder.domain.dto.response.FolderResponse; +import corecord.dev.domain.folder.domain.entity.Folder; +import corecord.dev.domain.user.domain.entity.User; + +import java.util.List; + +public class FolderConverter { + + public static Folder toFolderEntity(String title, User user) { + return Folder.builder() + .title(title) + .user(user) + .build(); + } + + public static FolderResponse.FolderDto toFolderDto(Folder folder) { + return FolderResponse.FolderDto.builder() + .folderId(folder.getFolderId()) + .title(folder.getTitle()) + .build(); + } + + public static FolderResponse.FolderDtoList toFolderDtoList(List folderDtoList) { + return FolderResponse.FolderDtoList.builder() + .folderDtoList(folderDtoList) + .build(); + } +} diff --git a/src/main/java/corecord/dev/domain/folder/domain/dto/request/FolderRequest.java b/src/main/java/corecord/dev/domain/folder/domain/dto/request/FolderRequest.java new file mode 100644 index 0000000..3e21fb0 --- /dev/null +++ b/src/main/java/corecord/dev/domain/folder/domain/dto/request/FolderRequest.java @@ -0,0 +1,23 @@ +package corecord.dev.domain.folder.domain.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Data; + +public class FolderRequest { + + @Data @Builder + public static class FolderDto { + @NotBlank(message = "폴더 명을 입력해주세요.") + private String title; + } + + @Data @Builder + public static class FolderUpdateDto { + @NotNull(message = "수정할 폴더 id를 입력해주세요.") + private Long folderId; + @NotBlank(message = "수정할 폴더 명을 입력해주세요.") + private String title; + } +} diff --git a/src/main/java/corecord/dev/domain/folder/domain/dto/response/FolderResponse.java b/src/main/java/corecord/dev/domain/folder/domain/dto/response/FolderResponse.java new file mode 100644 index 0000000..75617a4 --- /dev/null +++ b/src/main/java/corecord/dev/domain/folder/domain/dto/response/FolderResponse.java @@ -0,0 +1,24 @@ +package corecord.dev.domain.folder.domain.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Getter; + +import java.util.List; + +public class FolderResponse { + + @Builder @Getter + @AllArgsConstructor @Data + public static class FolderDto { + private Long folderId; + private String title; + } + + @Builder @Getter + @AllArgsConstructor @Data + public static class FolderDtoList { + private List folderDtoList; + } +} diff --git a/src/main/java/corecord/dev/domain/folder/domain/entity/Folder.java b/src/main/java/corecord/dev/domain/folder/domain/entity/Folder.java new file mode 100644 index 0000000..1f4464d --- /dev/null +++ b/src/main/java/corecord/dev/domain/folder/domain/entity/Folder.java @@ -0,0 +1,39 @@ +package corecord.dev.domain.folder.domain.entity; + +import corecord.dev.common.base.BaseEntity; +import corecord.dev.domain.record.domain.entity.Record; +import corecord.dev.domain.user.domain.entity.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Folder extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(nullable = false) + private Long folderId; + + @Column(nullable = false, length = 15) + private String title; + + @OneToMany(mappedBy = "folder", cascade = CascadeType.ALL, orphanRemoval = true) + private List records; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + public void updateTitle(String title) { + this.title = title; + } +} diff --git a/src/main/java/corecord/dev/domain/folder/domain/repository/FolderRepository.java b/src/main/java/corecord/dev/domain/folder/domain/repository/FolderRepository.java new file mode 100644 index 0000000..33593b9 --- /dev/null +++ b/src/main/java/corecord/dev/domain/folder/domain/repository/FolderRepository.java @@ -0,0 +1,38 @@ +package corecord.dev.domain.folder.domain.repository; + +import corecord.dev.domain.folder.domain.dto.response.FolderResponse; +import corecord.dev.domain.folder.domain.entity.Folder; +import corecord.dev.domain.user.domain.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface FolderRepository extends JpaRepository { + + @Query("SELECT new corecord.dev.domain.folder.domain.dto.response.FolderResponse$FolderDto(f.folderId, f.title) " + + "FROM Folder f " + + "WHERE f.user = :user " + + "ORDER BY f.createdAt desc ") + List findFolderDtoList(@Param(value = "user") User user); + + @Query("SELECT f " + + "FROM Folder f " + + "WHERE f.title = :title AND f.user = :user ") + Optional findFolderByTitle( + @Param(value = "title") String title, + @Param(value = "user") User user); + + boolean existsByTitle(String title); + + @Modifying + @Query("DELETE " + + "FROM Folder f " + + "WHERE f.user.userId IN :userId") + void deleteFolderByUserId(@Param(value = "userId") Long userId); +} diff --git a/src/main/java/corecord/dev/domain/folder/exception/FolderException.java b/src/main/java/corecord/dev/domain/folder/exception/FolderException.java new file mode 100644 index 0000000..1b66be1 --- /dev/null +++ b/src/main/java/corecord/dev/domain/folder/exception/FolderException.java @@ -0,0 +1,16 @@ +package corecord.dev.domain.folder.exception; + +import corecord.dev.domain.folder.status.FolderErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class FolderException extends RuntimeException { + private final FolderErrorStatus folderErrorStatus; + + @Override + public String getMessage() { + return folderErrorStatus.getMessage(); + } +} diff --git a/src/main/java/corecord/dev/domain/folder/presentation/FolderController.java b/src/main/java/corecord/dev/domain/folder/presentation/FolderController.java new file mode 100644 index 0000000..b8a97b5 --- /dev/null +++ b/src/main/java/corecord/dev/domain/folder/presentation/FolderController.java @@ -0,0 +1,56 @@ +package corecord.dev.domain.folder.presentation; + +import corecord.dev.common.response.ApiResponse; +import corecord.dev.common.web.UserId; +import corecord.dev.domain.folder.status.FolderSuccessStatus; +import corecord.dev.domain.folder.domain.dto.request.FolderRequest; +import corecord.dev.domain.folder.domain.dto.response.FolderResponse; +import corecord.dev.domain.folder.application.FolderService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/folders") +public class FolderController { + private final FolderService folderService; + + @PostMapping("") + public ResponseEntity> createFolder( + @UserId Long userId, + @RequestBody @Valid FolderRequest.FolderDto folderDto + ) { + FolderResponse.FolderDtoList folderResponse = folderService.createFolder(userId, folderDto); + return ApiResponse.success(FolderSuccessStatus.FOLDER_CREATE_SUCCESS, folderResponse); + } + + @DeleteMapping("/{folderId}") + public ResponseEntity> deleteFolder( + @UserId Long userId, + @PathVariable(name = "folderId") Long folderId + ) { + FolderResponse.FolderDtoList folderResponse = folderService.deleteFolder(userId, folderId); + return ApiResponse.success(FolderSuccessStatus.FOLDER_DELETE_SUCCESS, folderResponse); + } + + @GetMapping("") + public ResponseEntity> getFolders( + @UserId Long userId + ) { + FolderResponse.FolderDtoList folderResponse = folderService.getFolderList(userId); + return ApiResponse.success(FolderSuccessStatus.FOLDER_GET_SUCCESS, folderResponse); + } + + @PatchMapping("") + public ResponseEntity> updateFolder( + @UserId Long userId, + @RequestBody @Valid FolderRequest.FolderUpdateDto folderDto + ) { + FolderResponse.FolderDtoList folderResponse = folderService.updateFolder(userId, folderDto); + return ApiResponse.success(FolderSuccessStatus.FOLDER_UPDATE_SUCCESS, folderResponse); + } + + +} diff --git a/src/main/java/corecord/dev/domain/folder/status/FolderErrorStatus.java b/src/main/java/corecord/dev/domain/folder/status/FolderErrorStatus.java new file mode 100644 index 0000000..3ecd61f --- /dev/null +++ b/src/main/java/corecord/dev/domain/folder/status/FolderErrorStatus.java @@ -0,0 +1,19 @@ +package corecord.dev.domain.folder.status; + +import corecord.dev.common.base.BaseErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum FolderErrorStatus implements BaseErrorStatus { + DUPLICATED_FOLDER_TITLE(HttpStatus.BAD_REQUEST, "E0400_DUPLICATED_TITLE", "이미 존재하는 폴더 명입니다."), + OVERFLOW_FOLDER_TITLE(HttpStatus.BAD_REQUEST, "E0400_OVERFLOW_TITLE", "폴더 명은 15자 이내여야 합니다."), + USER_FOLDER_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "E401_FOLDER_UNAUTHORIZED", "유저가 폴더에 대한 권한이 없습니다."), + FOLDER_NOT_FOUND(HttpStatus.NOT_FOUND, "E0404_FOLDER", "존재하지 않는 폴더입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/corecord/dev/domain/folder/status/FolderSuccessStatus.java b/src/main/java/corecord/dev/domain/folder/status/FolderSuccessStatus.java new file mode 100644 index 0000000..8e22d3e --- /dev/null +++ b/src/main/java/corecord/dev/domain/folder/status/FolderSuccessStatus.java @@ -0,0 +1,20 @@ +package corecord.dev.domain.folder.status; + +import corecord.dev.common.base.BaseSuccessStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum FolderSuccessStatus implements BaseSuccessStatus { + + FOLDER_CREATE_SUCCESS(HttpStatus.CREATED, "S601", "폴더 생성이 성공적으로 완료되었습니다."), + FOLDER_DELETE_SUCCESS(HttpStatus.OK, "S605", "폴더 삭제가 성공적으로 완료되었습니다."), + FOLDER_GET_SUCCESS(HttpStatus.OK, "S603", "폴더 리스트 조회가 성공적으로 완료되었습니다."), + FOLDER_UPDATE_SUCCESS(HttpStatus.OK, "S604", "폴더명 수정이 성공적으로 완료되었습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/corecord/dev/domain/record/application/RecordService.java b/src/main/java/corecord/dev/domain/record/application/RecordService.java new file mode 100644 index 0000000..e72b93a --- /dev/null +++ b/src/main/java/corecord/dev/domain/record/application/RecordService.java @@ -0,0 +1,299 @@ +package corecord.dev.domain.record.application; + +import corecord.dev.common.exception.GeneralException; +import corecord.dev.common.status.ErrorStatus; +import corecord.dev.domain.ability.domain.entity.Keyword; +import corecord.dev.domain.ability.status.AbilityErrorStatus; +import corecord.dev.domain.ability.exception.AbilityException; +import corecord.dev.domain.analysis.application.AnalysisService; +import corecord.dev.domain.chat.domain.entity.ChatRoom; +import corecord.dev.domain.chat.status.ChatErrorStatus; +import corecord.dev.domain.chat.exception.ChatException; +import corecord.dev.domain.chat.domain.repository.ChatRoomRepository; +import corecord.dev.domain.folder.domain.entity.Folder; +import corecord.dev.domain.folder.status.FolderErrorStatus; +import corecord.dev.domain.folder.exception.FolderException; +import corecord.dev.domain.folder.domain.repository.FolderRepository; +import corecord.dev.domain.record.domain.entity.RecordType; +import corecord.dev.domain.record.domain.converter.RecordConverter; +import corecord.dev.domain.record.domain.dto.request.RecordRequest; +import corecord.dev.domain.record.domain.dto.response.RecordResponse; +import corecord.dev.domain.record.domain.entity.Record; +import corecord.dev.domain.record.status.RecordErrorStatus; +import corecord.dev.domain.record.exception.RecordException; +import corecord.dev.domain.record.domain.repository.RecordRepository; +import corecord.dev.domain.user.domain.entity.User; +import corecord.dev.domain.user.domain.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class RecordService { + private final RecordRepository recordRepository; + private final FolderRepository folderRepository; + private final UserRepository userRepository; + private final AnalysisService analysisService; + private final ChatRoomRepository chatRoomRepository; + private final int listSize = 30; + + /* + * user의 MEMO ver. 경험을 기록하고 폴더를 지정한 후 생성된 경험 기록 정보를 반환 + * @param userId, recordDto + * @return + */ + @Transactional + public RecordResponse.MemoRecordDto createMemoRecord(Long userId, RecordRequest.RecordDto recordDto) { + User user = findUserById(userId); + String title = recordDto.getTitle(); + String content = recordDto.getContent(); + Folder folder = findFolderById(recordDto.getFolderId()); + + // 제목, 본문 글자 수 검사 + validTextLength(title, content); + + // 경험 기록 종류에 따른 Record 생성 + Record record = createRecordBasedOnType(recordDto, user, folder); + + // Record 저장 + recordRepository.save(record); + + // 역량 분석 레포트 생성 + analysisService.createAnalysis(record, user); + + return RecordConverter.toMemoRecordDto(record); + } + + /* + * recordId를 받아 MEMO ver. 경험 기록의 상세 정보를 반환 + * @param userId, recordId + * @return + */ + @Transactional(readOnly = true) + public RecordResponse.MemoRecordDto getMemoRecordDetail(Long userId, Long recordId) { + User user = findUserById(userId); + Record record = findRecordById(recordId); + + // User-Record 권한 유효성 검증 + validIsUserAuthorizedForRecord(user, record); + + return RecordConverter.toMemoRecordDto(record); + } + + /* + * title, content를 받아 Record 객체를 생성한 후, recordId를 User.tmpMemo에 저장 + * @param userId, tmpMemoRecordDto + */ + @Transactional + public void createTmpMemoRecord(Long userId, RecordRequest.TmpMemoRecordDto tmpMemoRecordDto) { + User user = findUserById(userId); + String title = tmpMemoRecordDto.getTitle(); + String content = tmpMemoRecordDto.getContent(); + + // User의 임시 메모 저장 유무 확인 + validHasUserTmpMemo(user); + + // 제목, 본문 글자 수 검사 + validTextLength(title, content); + + // Record entity 생성 후 user.tmpMemo 필드에 recordId 저장 + Record record = RecordConverter.toMemoRecordEntity(title, content, user, null); + Record tmpRecord = recordRepository.save(record); + user.updateTmpMemo(tmpRecord.getRecordId()); + } + + /* + * user의 임시 저장된 메모 기록이 있다면 해당 Record row와 tmpMemo 필드 정보를 제거한 후 저장된 데이터를 반환 + * @param userId + * @return + */ + @Transactional + public RecordResponse.TmpMemoRecordDto getTmpMemoRecord(Long userId) { + User user = findUserById(userId); + Long tmpMemoRecordId = user.getTmpMemo(); + + // 임시 저장 내역이 없는 경우 isExist=false 반환 + if (tmpMemoRecordId == null) { + return RecordConverter.toNotExistingTmpMemoRecordDto(); + } + + // 임시 저장 내역이 있는 경우 결과 조회 + Record tmpMemoRecord = findTmpRecordById(tmpMemoRecordId); + + // 기존 데이터 제거 후 결과 반환 + user.deleteTmpMemo(); + recordRepository.delete(tmpMemoRecord); + return RecordConverter.toExistingTmpMemoRecordDto(tmpMemoRecord); + } + + /* + * 폴더별 경험 기록 리스트를 반환합니다. folder의 default value는 'all'입니다. + * @param userId, folderName + * @return + */ + @Transactional(readOnly = true) + public RecordResponse.RecordListDto getRecordList(Long userId, String folderName, Long lastRecordId) { + User user = findUserById(userId); + List recordList; + + // 임시 저장 기록 제외 Record List 최신 생성 순 조회 + if (folderName.equals("all")) { + recordList = findRecordList(user, lastRecordId); + } else { + Folder folder = findFolderByTitle(user, folderName); + recordList = findRecordListByFolder(user, folder, lastRecordId); + } + + // 다음 조회할 데이터가 남아있는지 확인 + boolean hasNext = recordList.size() == listSize + 1; + if (hasNext) + recordList = recordList.subList(0, listSize); + + return RecordConverter.toRecordListDto(folderName, recordList, hasNext); + } + + /* + * keyword를 받아 해당 키워드를 가진 역량 분석 정보와 경험 기록 정보를 반환 + * @param userId, keywordValue + * @return + */ + @Transactional(readOnly = true) + public RecordResponse.KeywordRecordListDto getKeywordRecordList(Long userId, String keywordValue, Long lastRecordId) { + User user = findUserById(userId); + + // 해당 keyword를 가진 ability 객체 조회 후 맵핑된 Record 객체 리스트 조회 + Keyword keyword = getKeyword(keywordValue); + List recordList = findRecordListByKeyword(user, keyword, lastRecordId); + + // 다음 조회할 데이터가 남아있는지 확인 + boolean hasNext = recordList.size() == listSize + 1; + if (hasNext) + recordList = recordList.subList(0, listSize); + + return RecordConverter.toKeywordRecordListDto(recordList, hasNext); + } + + /* + * record가 속한 폴더를 변경 + * @param userId, updateFolderDto + */ + @Transactional + public void updateFolder(Long userId, RecordRequest.UpdateFolderDto updateFolderDto) { + User user = findUserById(userId); + Record record = findRecordById(updateFolderDto.getRecordId()); + Folder folder = findFolderByTitle(user, updateFolderDto.getFolder()); + + record.updateFolder(folder); + } + + /* + * 최근 생성된 경험 기록 리스트 3개를 반환 + * @param userId + * @return + */ + public RecordResponse.RecordListDto getRecentRecordList(Long userId) { + User user = findUserById(userId); + + // 최근 생성된 3개의 데이터만 조회 + List recordList = findRecordListOrderByCreatedAt(user); + + return RecordConverter.toRecordListDto("all", recordList, false); + } + + private void validHasUserTmpMemo(User user) { + if (user.getTmpMemo() != null) + throw new RecordException(RecordErrorStatus.ALREADY_TMP_MEMO); + } + + private void validTextLength(String title, String content) { + if (title != null && title.length() > 50) + throw new RecordException(RecordErrorStatus.OVERFLOW_MEMO_RECORD_TITLE); + + if (content != null && content.length() < 30) + throw new RecordException(RecordErrorStatus.NOT_ENOUGH_MEMO_RECORD_CONTENT); + + if (content != null && content.length() > 500) { + throw new RecordException(RecordErrorStatus.OVERFLOW_MEMO_RECORD_CONTENT); + } + } + + // user-record 권한 검사 + private void validIsUserAuthorizedForRecord(User user, Record record) { + if (!record.getUser().equals(user)) + throw new RecordException(RecordErrorStatus.USER_RECORD_UNAUTHORIZED); + } + + private Folder findFolderById(Long folderId) { + return folderRepository.findById(folderId) + .orElseThrow(() -> new FolderException(FolderErrorStatus.FOLDER_NOT_FOUND)); + } + + private Folder findFolderByTitle(User user, String title) { + return folderRepository.findFolderByTitle(title, user) + .orElseThrow(() -> new FolderException(FolderErrorStatus.FOLDER_NOT_FOUND)); + } + + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.UNAUTHORIZED)); + } + + private Record findRecordById(Long recordId) { + return recordRepository.findRecordById(recordId) + .orElseThrow(() -> new RecordException(RecordErrorStatus.RECORD_NOT_FOUND)); + } + + private Record findTmpRecordById(Long recordId) { + return recordRepository.findById(recordId) + .orElseThrow(() -> new RecordException(RecordErrorStatus.RECORD_NOT_FOUND)); + } + + private List findRecordListByFolder(User user, Folder folder, Long lastRecordId) { + Pageable pageable = PageRequest.of(0, listSize + 1, Sort.by("createdAt").descending()); + return recordRepository.findRecordsByFolder(folder, user, lastRecordId, pageable); + } + + private List findRecordList(User user, Long lastRecordId) { + Pageable pageable = PageRequest.of(0, listSize + 1, Sort.by("createdAt").descending()); + return recordRepository.findRecords(user, lastRecordId, pageable); + } + + private List findRecordListOrderByCreatedAt(User user) { + Pageable pageable = PageRequest.of(0, 6, Sort.by("createdAt").descending()); + return recordRepository.findRecordsOrderByCreatedAt(user, pageable); + } + + private List findRecordListByKeyword(User user, Keyword keyword, Long lastRecordId) { + Pageable pageable = PageRequest.of(0, listSize + 1, Sort.by("createdAt").descending()); + return recordRepository.findRecordsByKeyword(keyword, user, lastRecordId, pageable); + } + + private Keyword getKeyword(String keywordValue) { + Keyword keyword = Keyword.getName(keywordValue); + if (keyword == null) + throw new AbilityException(AbilityErrorStatus.INVALID_KEYWORD); + return keyword; + } + + private Record createRecordBasedOnType(RecordRequest.RecordDto recordDto, User user, Folder folder) { + if (recordDto.getRecordType() == RecordType.MEMO) { + return RecordConverter.toMemoRecordEntity(recordDto.getTitle(), recordDto.getContent(), user, folder); + } else { + ChatRoom chatRoom = findChatRoomById(recordDto.getChatRoomId(), user); + return RecordConverter.toChatRecordEntity(recordDto.getTitle(), recordDto.getContent(), user, folder, chatRoom); + } + } + + private ChatRoom findChatRoomById(Long chatRoomId, User user) { + return chatRoomRepository.findByChatRoomIdAndUser(chatRoomId, user) + .orElseThrow(() -> new ChatException(ChatErrorStatus.CHAT_ROOM_NOT_FOUND)); + } +} diff --git a/src/main/java/corecord/dev/domain/record/domain/converter/RecordConverter.java b/src/main/java/corecord/dev/domain/record/domain/converter/RecordConverter.java new file mode 100644 index 0000000..54c689f --- /dev/null +++ b/src/main/java/corecord/dev/domain/record/domain/converter/RecordConverter.java @@ -0,0 +1,114 @@ +package corecord.dev.domain.record.domain.converter; + +import corecord.dev.domain.ability.domain.entity.Keyword; +import corecord.dev.domain.ability.domain.entity.Ability; +import corecord.dev.domain.chat.domain.entity.ChatRoom; +import corecord.dev.domain.folder.domain.entity.Folder; +import corecord.dev.domain.record.domain.entity.RecordType; +import corecord.dev.domain.record.domain.dto.response.RecordResponse; +import corecord.dev.domain.record.domain.entity.Record; +import corecord.dev.domain.user.domain.entity.User; + +import java.util.List; + +public class RecordConverter { + public static Record toMemoRecordEntity(String title, String content, User user, Folder folder) { + return Record.builder() + .title(title) + .user(user) + .content(content) + .folder(folder) + .type(RecordType.MEMO) + .build(); + } + + public static Record toChatRecordEntity(String title, String content, User user, Folder folder, ChatRoom chatRoom) { + return Record.builder() + .title(title) + .user(user) + .content(content) + .folder(folder) + .chatRoom(chatRoom) + .type(RecordType.CHAT) + .build(); + } + + public static RecordResponse.MemoRecordDto toMemoRecordDto(Record record) { + return RecordResponse.MemoRecordDto.builder() + .recordId(record.getRecordId()) + .title(record.getTitle()) + .content(record.getContent()) + .folder(record.getFolder().getTitle()) + .createdAt(record.getCreatedAtFormatted()) + .build(); + } + + public static RecordResponse.TmpMemoRecordDto toExistingTmpMemoRecordDto(Record record) { + return RecordResponse.TmpMemoRecordDto.builder() + .isExist(true) + .title(record.getTitle()) + .content(record.getContent()) + .build(); + } + + public static RecordResponse.TmpMemoRecordDto toNotExistingTmpMemoRecordDto() { + return RecordResponse.TmpMemoRecordDto.builder() + .isExist(false) + .title(null) + .content(null) + .build(); + } + + public static RecordResponse.RecordDto toRecordDto(Record record) { + List keywordList = record.getAnalysis().getAbilityList().stream() + .map(Ability::getKeyword) + .map(Keyword::getValue) + .toList(); + + return RecordResponse.RecordDto.builder() + .analysisId(record.getAnalysis().getAnalysisId()) + .recordId(record.getRecordId()) + .folder(record.getFolder().getTitle()) + .title(record.getTitle()) + .keywordList(keywordList) + .createdAt(record.getCreatedAtFormatted()) + .build(); + } + + public static RecordResponse.RecordListDto toRecordListDto(String folder, List recordList, boolean hasNext) { + List recordDtoList = recordList.stream() + .map(RecordConverter::toRecordDto) + .toList(); + + return RecordResponse.RecordListDto.builder() + .folder(folder) + .recordDtoList(recordDtoList) + .hasNext(hasNext) + .build(); + } + + public static RecordResponse.KeywordRecordDto toKeywordRecordDto(Record record) { + String content = record.getContent(); + String truncatedContent = content.length() > 30 ? content.substring(0, 30) : content; + + return RecordResponse.KeywordRecordDto.builder() + .analysisId(record.getAnalysis().getAnalysisId()) + .recordId(record.getRecordId()) + .folder(record.getFolder().getTitle()) + .title(record.getTitle()) + .content(truncatedContent) + .createdAt(record.getCreatedAtFormatted()) + .build(); + } + + public static RecordResponse.KeywordRecordListDto toKeywordRecordListDto(List recordList, boolean hasNext) { + List keywordRecordDtoList = recordList.stream() + .map(RecordConverter::toKeywordRecordDto) + .toList(); + + return RecordResponse.KeywordRecordListDto.builder() + .recordDtoList(keywordRecordDtoList) + .hasNext(hasNext) + .build(); + } +} diff --git a/src/main/java/corecord/dev/domain/record/domain/dto/request/RecordRequest.java b/src/main/java/corecord/dev/domain/record/domain/dto/request/RecordRequest.java new file mode 100644 index 0000000..761efda --- /dev/null +++ b/src/main/java/corecord/dev/domain/record/domain/dto/request/RecordRequest.java @@ -0,0 +1,38 @@ +package corecord.dev.domain.record.domain.dto.request; + +import corecord.dev.domain.record.domain.entity.RecordType; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +public class RecordRequest { + @Data @Builder + public static class RecordDto { + @NotBlank(message = "제목을 입력해주세요.") + private String title; + @NotBlank(message = "내용을 입력해주세요.") + private String content; + @NotNull(message = "저장할 폴더의 id를 입력해주세요.") + private Long folderId; + @NotBlank(message = "저장할 기록의 타입을 입력해주세요.") + private RecordType recordType; + private Long chatRoomId; + } + + @Data @Builder + public static class TmpMemoRecordDto { + @NotBlank(message = "임시 저장할 기록의 제목을 입력해주세요.") + private String title; + @NotBlank(message = "임시 저장할 내용을 입력해주세요.") + private String content; + } + + @Data + public static class UpdateFolderDto { + @NotNull(message = "변경할 경험 기록의 id를 입력해주세요.") + private Long recordId; + @NotBlank(message = "변경할 폴더를 입력해주세요.") + private String folder; + } +} diff --git a/src/main/java/corecord/dev/domain/record/domain/dto/response/RecordResponse.java b/src/main/java/corecord/dev/domain/record/domain/dto/response/RecordResponse.java new file mode 100644 index 0000000..35fde01 --- /dev/null +++ b/src/main/java/corecord/dev/domain/record/domain/dto/response/RecordResponse.java @@ -0,0 +1,77 @@ +package corecord.dev.domain.record.domain.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Getter; + +import java.util.List; + +public class RecordResponse { + @Builder + @Getter + @AllArgsConstructor + @Data + public static class MemoRecordDto { + private Long recordId; + private String title; + private String content; + private String folder; + private String createdAt; + } + + @Builder + @Getter + @AllArgsConstructor + @Data + public static class TmpMemoRecordDto { + private Boolean isExist; + private String title; + private String content; + } + + @Builder + @Getter + @AllArgsConstructor + @Data + public static class RecordDto { + private Long analysisId; + private Long recordId; + private String folder; + private String title; + private List keywordList; + private String createdAt; + } + + @Builder + @Getter + @AllArgsConstructor + @Data + public static class RecordListDto { + private String folder; + private List recordDtoList; + private boolean hasNext; + } + + @Builder + @Getter + @AllArgsConstructor + @Data + public static class KeywordRecordDto { + private Long analysisId; + private Long recordId; + private String folder; + private String title; + private String content; + private String createdAt; + } + + @Builder + @Getter + @AllArgsConstructor + @Data + public static class KeywordRecordListDto { + private List recordDtoList; + private boolean hasNext; + } +} diff --git a/src/main/java/corecord/dev/domain/record/domain/entity/Record.java b/src/main/java/corecord/dev/domain/record/domain/entity/Record.java new file mode 100644 index 0000000..8e11fb9 --- /dev/null +++ b/src/main/java/corecord/dev/domain/record/domain/entity/Record.java @@ -0,0 +1,62 @@ +package corecord.dev.domain.record.domain.entity; + +import corecord.dev.common.base.BaseEntity; +import corecord.dev.domain.analysis.domain.entity.Analysis; +import corecord.dev.domain.chat.domain.entity.ChatRoom; +import corecord.dev.domain.folder.domain.entity.Folder; +import corecord.dev.domain.user.domain.entity.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter @Builder +@AllArgsConstructor +@NoArgsConstructor +public class Record extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(nullable = false) + private Long recordId; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private RecordType type; + + @Column(length = 50) + private String title; + + @Column(nullable = false, length = 500) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id", nullable = true) + private ChatRoom chatRoom; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "folder_id", nullable = true) + private Folder folder; + + @OneToOne(mappedBy = "record", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private Analysis analysis; + + public void updateFolder(Folder folder) { + this.folder = folder; + } + + public void updateTitle(String title) { + if (title != null && !title.isEmpty()) { + this.title = title; + } + } + + public boolean isMemoType() { + return this.type == RecordType.MEMO; + } +} diff --git a/src/main/java/corecord/dev/domain/record/domain/entity/RecordType.java b/src/main/java/corecord/dev/domain/record/domain/entity/RecordType.java new file mode 100644 index 0000000..526ca39 --- /dev/null +++ b/src/main/java/corecord/dev/domain/record/domain/entity/RecordType.java @@ -0,0 +1,5 @@ +package corecord.dev.domain.record.domain.entity; + +public enum RecordType { + MEMO, CHAT +} diff --git a/src/main/java/corecord/dev/domain/record/domain/repository/RecordRepository.java b/src/main/java/corecord/dev/domain/record/domain/repository/RecordRepository.java new file mode 100644 index 0000000..3a3b1d8 --- /dev/null +++ b/src/main/java/corecord/dev/domain/record/domain/repository/RecordRepository.java @@ -0,0 +1,88 @@ +package corecord.dev.domain.record.domain.repository; + +import corecord.dev.domain.ability.domain.entity.Keyword; +import corecord.dev.domain.folder.domain.entity.Folder; +import corecord.dev.domain.record.domain.entity.Record; +import corecord.dev.domain.user.domain.entity.User; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface RecordRepository extends JpaRepository { + + @Query("SELECT r " + + "FROM Record r " + + "JOIN FETCH r.analysis a " + + "JOIN FETCH r.folder f " + + "JOIN FETCH a.abilityList al " + + "WHERE r.user = :user " + + "AND (:last_record_id = 0 OR r.recordId < :last_record_id) " + // 제일 마지막에 읽은 데이터 이후부터 가져옴 + "AND r.folder is not null AND r.folder = :folder") // 임시 저장 기록 제외 + List findRecordsByFolder( + @Param(value = "folder") Folder folder, + @Param(value = "user") User user, + @Param(value = "last_record_id") Long lastRecordId, + Pageable pageable); + + @Query("SELECT r FROM Record r " + + "JOIN FETCH r.analysis a " + + "JOIN FETCH r.folder f " + + "JOIN FETCH a.abilityList al " + + "WHERE r.user = :user " + + "AND (:last_record_id = 0 OR r.recordId < :last_record_id) " + // 제일 마지막에 읽은 데이터 이후부터 가져옴 + "AND r.folder is not null") // 임시 저장 기록 제외 + List findRecords( + @Param(value = "user") User user, + @Param(value = "last_record_id") Long lastRecordId, + Pageable pageable); + + + @Query("SELECT r FROM Ability a " + + "JOIN a.analysis an " + + "JOIN an.record r " + + "JOIN FETCH r.folder f " + + "WHERE a.user = :user " + + "AND a.keyword = :keyword " + + "AND (:last_record_id = 0 OR r.recordId < :last_record_id) " + // 제일 마지막에 읽은 데이터 이후부터 가져옴 + "AND r.folder is not null") // 임시 저장 기록 제외 + List findRecordsByKeyword( + @Param(value = "keyword")Keyword keyword, + @Param(value = "user") User user, + @Param(value = "last_record_id") Long lastRecordId, + Pageable pageable + ); + + @Query("SELECT r FROM Record r " + + "JOIN FETCH r.analysis a " + + "JOIN FETCH r.folder f " + + "JOIN FETCH a.abilityList al " + + "WHERE r.user = :user " + + "AND r.folder is not null ") // 임시 저장 기록 제외 + List findRecordsOrderByCreatedAt( + @Param(value = "user") User user, + Pageable pageable); + + @Query("SELECT r FROM Record r " + + "JOIN FETCH r.analysis a " + + "JOIN FETCH r.folder f " + + "WHERE r.recordId = :id") + Optional findRecordById(@Param(value = "id") Long id); + + @Query("SELECT COUNT(r) " + + "FROM Record r " + + "WHERE r.user = :user") + int getRecordCount(@Param(value = "user") User user); + + @Modifying + @Query("DELETE " + + "FROM Record r " + + "WHERE r.user.userId IN :userId") + void deleteRecordByUserId(@Param(value = "userId") Long userId); +} diff --git a/src/main/java/corecord/dev/domain/record/exception/RecordException.java b/src/main/java/corecord/dev/domain/record/exception/RecordException.java new file mode 100644 index 0000000..9ec52c8 --- /dev/null +++ b/src/main/java/corecord/dev/domain/record/exception/RecordException.java @@ -0,0 +1,16 @@ +package corecord.dev.domain.record.exception; + +import corecord.dev.domain.record.status.RecordErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class RecordException extends RuntimeException { + private final RecordErrorStatus recordErrorStatus; + + @Override + public String getMessage() { + return recordErrorStatus.getMessage(); + } +} diff --git a/src/main/java/corecord/dev/domain/record/presentation/RecordController.java b/src/main/java/corecord/dev/domain/record/presentation/RecordController.java new file mode 100644 index 0000000..1831f00 --- /dev/null +++ b/src/main/java/corecord/dev/domain/record/presentation/RecordController.java @@ -0,0 +1,92 @@ +package corecord.dev.domain.record.presentation; + +import corecord.dev.common.response.ApiResponse; +import corecord.dev.common.web.UserId; +import corecord.dev.domain.record.status.RecordSuccessStatus; +import corecord.dev.domain.record.domain.dto.request.RecordRequest; +import corecord.dev.domain.record.domain.dto.response.RecordResponse; +import corecord.dev.domain.record.application.RecordService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/records") +public class RecordController { + private final RecordService recordService; + + @PostMapping("") + public ResponseEntity> createMemoRecord( + @UserId Long userId, + @RequestBody RecordRequest.RecordDto recordDto + ) { + RecordResponse.MemoRecordDto recordResponse = recordService.createMemoRecord(userId, recordDto); + return ApiResponse.success(RecordSuccessStatus.MEMO_RECORD_CREATE_SUCCESS, recordResponse); + } + + @GetMapping("/memo/{recordId}") + public ResponseEntity> getMemoRecordDetail( + @UserId Long userId, + @PathVariable(name = "recordId") Long recordId + ) { + RecordResponse.MemoRecordDto recordResponse = recordService.getMemoRecordDetail(userId, recordId); + return ApiResponse.success(RecordSuccessStatus.MEMO_RECORD_DETAIL_GET_SUCCESS, recordResponse); + } + + @PostMapping("/memo/tmp") + public ResponseEntity> saveTmpMemoRecord( + @UserId Long userId, + @RequestBody @Valid RecordRequest.TmpMemoRecordDto tmpMemoRecordDto + ) { + recordService.createTmpMemoRecord(userId, tmpMemoRecordDto); + return ApiResponse.success(RecordSuccessStatus.MEMO_RECORD_TMP_CREATE_SUCCESS); + } + + @GetMapping("/memo/tmp") + public ResponseEntity> getTmpMemoRecord( + @UserId Long userId + ) { + RecordResponse.TmpMemoRecordDto recordResponse = recordService.getTmpMemoRecord(userId); + return ApiResponse.success(RecordSuccessStatus.MEMO_RECORD_TMP_GET_SUCCESS, recordResponse); + } + + @GetMapping("") + public ResponseEntity> getRecordListByFolder( + @UserId Long userId, + @RequestParam(name = "folder", defaultValue = "all") String folder, + @RequestParam(name = "lastRecordId", defaultValue = "0") Long lastRecordId + ) { + RecordResponse.RecordListDto recordResponse = recordService.getRecordList(userId, folder, lastRecordId); + return ApiResponse.success(RecordSuccessStatus.RECORD_LIST_GET_SUCCESS, recordResponse); + } + + @GetMapping("/keyword") + public ResponseEntity> getRecordListByKeyword( + @UserId Long userId, + @RequestParam(name = "keyword") String keyword, + @RequestParam(name = "lastRecordId", defaultValue = "0") Long lastRecordId + ) { + RecordResponse.KeywordRecordListDto recordResponse = recordService.getKeywordRecordList(userId, keyword, lastRecordId); + return ApiResponse.success(RecordSuccessStatus.KEYWORD_RECORD_LIST_GET_SUCCESS, recordResponse); + } + + @PatchMapping("/folder") + public ResponseEntity> updateRecordForFolder( + @UserId Long userId, + @RequestBody @Valid RecordRequest.UpdateFolderDto updateFolderDto + ) { + recordService.updateFolder(userId, updateFolderDto); + return ApiResponse.success(RecordSuccessStatus.RECORD_FOLDER_UPDATE_SUCCESS); + } + + @GetMapping("/recent") + public ResponseEntity> getRecentRecordList( + @UserId Long userId + ) { + RecordResponse.RecordListDto recordResponse = recordService.getRecentRecordList(userId); + return ApiResponse.success(RecordSuccessStatus.RECENT_RECORD_LIST_GET_SUCCESS, recordResponse); + } + +} diff --git a/src/main/java/corecord/dev/domain/record/status/RecordErrorStatus.java b/src/main/java/corecord/dev/domain/record/status/RecordErrorStatus.java new file mode 100644 index 0000000..ffd8136 --- /dev/null +++ b/src/main/java/corecord/dev/domain/record/status/RecordErrorStatus.java @@ -0,0 +1,22 @@ +package corecord.dev.domain.record.status; + +import corecord.dev.common.base.BaseErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum RecordErrorStatus implements BaseErrorStatus { + OVERFLOW_MEMO_RECORD_TITLE(HttpStatus.BAD_REQUEST, "E0400_OVERFLOW_TITLE", "메모 제목은 50자 이내여야 합니다."), + OVERFLOW_MEMO_RECORD_CONTENT(HttpStatus.BAD_REQUEST, "E0400_OVERFLOW_CONTENT", "메모 내용은 500자 이내여야 합니다."), + NOT_ENOUGH_MEMO_RECORD_CONTENT(HttpStatus.BAD_REQUEST, "E0400_NOT_ENOUGH_CONTENT", "메모 내용은 30자 이상이어야 합니다."), + USER_RECORD_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "E401_RECORD_UNAUTHORIZED", "유저가 경험 기록에 대한 권한이 없습니다."), + RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "E0404_RECORD", "존재하지 않는 경험 기록입니다."), + ALREADY_TMP_MEMO(HttpStatus.BAD_REQUEST, "E0400_TMP_MEMO", "유저가 이미 임시 저장된 메모를 가지고 있습니다.") + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/corecord/dev/domain/record/status/RecordSuccessStatus.java b/src/main/java/corecord/dev/domain/record/status/RecordSuccessStatus.java new file mode 100644 index 0000000..44358f7 --- /dev/null +++ b/src/main/java/corecord/dev/domain/record/status/RecordSuccessStatus.java @@ -0,0 +1,25 @@ +package corecord.dev.domain.record.status; + +import corecord.dev.common.base.BaseSuccessStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum RecordSuccessStatus implements BaseSuccessStatus { + + MEMO_RECORD_CREATE_SUCCESS(HttpStatus.CREATED, "S404", "경험 기록이 성공적으로 완료되었습니다."), + MEMO_RECORD_DETAIL_GET_SUCCESS(HttpStatus.OK, "S401", "메모 경험 기록 세부 조회가 성공적으로 완료되었습니다."), + MEMO_RECORD_TMP_CREATE_SUCCESS(HttpStatus.OK, "S403", "메모 경험 기록 임시 저장이 성공적으로 완료되었습니다."), + MEMO_RECORD_TMP_GET_SUCCESS(HttpStatus.OK, "S402", "메모 경험 기록 임시 저장 내역 조회가 성공적으로 완료되었습니다."), + RECORD_LIST_GET_SUCCESS(HttpStatus.OK, "S602", "폴더별 경험 기록 리스트 조회가 성공적으로 완료되었습니다."), + KEYWORD_RECORD_LIST_GET_SUCCESS(HttpStatus.OK, "S503", "역량 키워드별 경험 기록 리스트 조회가 성공적으로 완료되었습니다."), + RECORD_FOLDER_UPDATE_SUCCESS(HttpStatus.OK, "S504", "경험 기록의 폴더 변경이 성공적으로 완료되었습니다."), + RECENT_RECORD_LIST_GET_SUCCESS(HttpStatus.OK, "S201", "최근 생성된 경험 기록 리스트 조회가 성공적으로 완료되었습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/corecord/dev/domain/user/application/UserService.java b/src/main/java/corecord/dev/domain/user/application/UserService.java new file mode 100644 index 0000000..4b199cd --- /dev/null +++ b/src/main/java/corecord/dev/domain/user/application/UserService.java @@ -0,0 +1,203 @@ +package corecord.dev.domain.user.application; + +import corecord.dev.common.exception.GeneralException; +import corecord.dev.common.status.ErrorStatus; +import corecord.dev.common.util.CookieUtil; +import corecord.dev.domain.auth.jwt.JwtUtil; +import corecord.dev.domain.ability.domain.repository.AbilityRepository; +import corecord.dev.domain.analysis.domain.repository.AnalysisRepository; +import corecord.dev.domain.folder.domain.repository.FolderRepository; +import corecord.dev.domain.record.domain.repository.RecordRepository; +import corecord.dev.domain.auth.domain.entity.RefreshToken; +import corecord.dev.domain.auth.status.TokenErrorStatus; +import corecord.dev.domain.auth.exception.TokenException; +import corecord.dev.domain.auth.domain.repository.RefreshTokenRepository; +import corecord.dev.domain.user.domain.converter.UserConverter; +import corecord.dev.domain.user.domain.dto.request.UserRequest; +import corecord.dev.domain.user.domain.dto.response.UserResponse; +import corecord.dev.domain.user.domain.entity.Status; +import corecord.dev.domain.user.domain.entity.User; +import corecord.dev.domain.user.status.UserErrorStatus; +import corecord.dev.domain.user.exception.UserException; +import corecord.dev.domain.user.domain.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; + +import java.util.Optional; +import java.util.regex.Pattern; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserService { + + private final JwtUtil jwtUtil; + private final CookieUtil cookieUtil; + private final UserRepository userRepository; + private final RecordRepository recordRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final FolderRepository folderRepository; + private final AnalysisRepository analysisRepository; + private final AbilityRepository abilityRepository; + + @Value("${jwt.access-token.expiration-time}") + private long accessTokenExpirationTime; + + @Value("${jwt.refresh-token.expiration-time}") + private long refreshTokenExpirationTime; + + @Transactional + public UserResponse.UserDto registerUser(HttpServletResponse response, String registerToken, UserRequest.UserRegisterDto userRegisterDto) { + // registerToken 유효성 검증 + validRegisterToken(registerToken); + + // user 정보 유효성 검증 + validateUserInfo(userRegisterDto.getNickName()); + + String providerId = jwtUtil.getProviderIdFromToken(registerToken); + // 이미 존재하는 유저인지 확인 + checkExistUser(providerId); + + // 새로운 유저 생성 + User newUser = UserConverter.toUserEntity(userRegisterDto, providerId); + User savedUser = userRepository.save(newUser); + + // RefreshToken 생성 및 저장 + String refreshToken = jwtUtil.generateRefreshToken(savedUser.getUserId()); + saveRefreshToken(refreshToken, savedUser); + + // AccessToken 및 RefreshToken 쿠키 설정 + setTokenCookies(response, "accessToken", jwtUtil.generateAccessToken(savedUser.getUserId())); + setTokenCookies(response, "refreshToken", refreshToken); + return UserConverter.toUserDto(savedUser); + } + + // 로그아웃 + @Transactional + public void logoutUser(HttpServletRequest request, HttpServletResponse response) { + // RefreshToken 삭제 + deleteRefreshTokenInRedis(request); + deleteTokenCookies(response); + } + + // 유저 삭제 + @Transactional + public void deleteUser(HttpServletRequest request, HttpServletResponse response, Long userId) { + // 연관된 데이터 삭제 + abilityRepository.deleteAbilityByUserId(userId); + analysisRepository.deleteAnalysisByUserId(userId); + recordRepository.deleteRecordByUserId(userId); + folderRepository.deleteFolderByUserId(userId); + userRepository.deleteUserByUserId(userId); + + // RefreshToken 삭제 + deleteRefreshTokenInRedis(request); + deleteTokenCookies(response); + } + + // 유저 정보 수정 + @Transactional + public void updateUser(Long userId, UserRequest.UserUpdateDto userUpdateDto) { + User user = getUser(userId); + + if(userUpdateDto.getNickName() != null) { + validateUserInfo(userUpdateDto.getNickName()); + user.setNickName(userUpdateDto.getNickName()); + } + + if(userUpdateDto.getStatus() != null) { + user.setStatus(Status.getStatus(userUpdateDto.getStatus())); + } + } + + // 유저 정보 조회 + @Transactional + public UserResponse.UserInfoDto getUserInfo(Long userId) { + User user = getUser(userId); + + int recordCount = getRecordCount(user); + return UserConverter.toUserInfoDto(user, recordCount); + } + + private int getRecordCount(User user) { + int recordCount = recordRepository.getRecordCount(user); + if (user.getTmpChat() != null) { + recordCount--; + } + if (user.getTmpMemo() != null) { + recordCount--; + } + return recordCount; + } + + // RefreshToken 저장 + private void saveRefreshToken(String refreshToken, User user) { + RefreshToken newRefreshToken = RefreshToken.of(refreshToken, user.getUserId()); + refreshTokenRepository.save(newRefreshToken); + } + + // 토큰 쿠키 설정 + private void setTokenCookies(HttpServletResponse response, String tokenName, String token) { + if(tokenName.equals("accessToken")) { + ResponseCookie accessTokenCookie = cookieUtil.createTokenCookie(tokenName, token, accessTokenExpirationTime); + response.addHeader("Set-Cookie", accessTokenCookie.toString()); + } else { + ResponseCookie refreshTokenCookie = cookieUtil.createTokenCookie(tokenName, token, refreshTokenExpirationTime); + response.addHeader("Set-Cookie", refreshTokenCookie.toString()); + } + } + + private void deleteTokenCookies(HttpServletResponse response) { + ResponseCookie accessTokenCookie = cookieUtil.deleteCookie("accessToken"); + response.addHeader("Set-Cookie", accessTokenCookie.toString()); + ResponseCookie refreshTokenCookie = cookieUtil.deleteCookie("refreshToken"); + response.addHeader("Set-Cookie", refreshTokenCookie.toString()); + } + + private void deleteRefreshTokenInRedis(HttpServletRequest request) { + String refreshToken = cookieUtil.getCookieValue(request, "refreshToken"); + if (refreshToken == null || refreshToken.isEmpty()) { + log.info("쿠키에 리프레쉬 토큰 없음"); + return; + } + Optional refreshTokenOptional = refreshTokenRepository.findByRefreshToken(cookieUtil.getCookieValue(request, "refreshToken")); + refreshTokenOptional.ifPresent(refreshTokenRepository::delete); + } + + private void checkExistUser(String providerId) { + if (userRepository.existsByProviderId(providerId)) { + throw new UserException(UserErrorStatus.ALREADY_EXIST_USER); + } + } + + // user 정보 유효성 검증 + private void validateUserInfo(String nickName) { + if (nickName == null || nickName.isEmpty() || nickName.length() > 10) { + throw new UserException(UserErrorStatus.INVALID_USER_NICKNAME); + } + + // 한글, 영어, 숫자, 공백만 허용 + String nicknamePattern = "^[a-zA-Z0-9ㄱ-ㅎㅏ-ㅣ가-힣\s]*$"; + if (!Pattern.matches(nicknamePattern, nickName)) { + throw new UserException(UserErrorStatus.INVALID_USER_NICKNAME); + } + } + + // registerToken 유효성 검증 + private void validRegisterToken(String registerToken) { + if (!jwtUtil.isRegisterTokenValid(registerToken)) { + throw new TokenException(TokenErrorStatus.INVALID_REGISTER_TOKEN); + } + } + + private User getUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.UNAUTHORIZED)); + } +} diff --git a/src/main/java/corecord/dev/domain/user/domain/converter/UserConverter.java b/src/main/java/corecord/dev/domain/user/domain/converter/UserConverter.java new file mode 100644 index 0000000..fb6386e --- /dev/null +++ b/src/main/java/corecord/dev/domain/user/domain/converter/UserConverter.java @@ -0,0 +1,34 @@ +package corecord.dev.domain.user.domain.converter; + +import corecord.dev.domain.user.domain.dto.request.UserRequest; +import corecord.dev.domain.user.domain.dto.response.UserResponse; +import corecord.dev.domain.user.domain.entity.Status; +import corecord.dev.domain.user.domain.entity.User; + +public class UserConverter { + + public static User toUserEntity(UserRequest.UserRegisterDto request, String providerId) { + return User.builder() + .providerId(providerId) + .nickName(request.getNickName()) + .status(Status.getStatus(request.getStatus())) + .build(); + } + + public static UserResponse.UserDto toUserDto(User user) { + return UserResponse.UserDto.builder() + .userId(user.getUserId()) + .nickname(user.getNickName()) + .status(user.getStatus().getValue()) + .build(); + } + + public static UserResponse.UserInfoDto toUserInfoDto(User user, int recordCount) { + return UserResponse.UserInfoDto.builder() + .recordCount(recordCount) + .nickname(user.getNickName()) + .status(user.getStatus().getValue()) + .build(); + } + +} diff --git a/src/main/java/corecord/dev/domain/user/domain/dto/request/UserRequest.java b/src/main/java/corecord/dev/domain/user/domain/dto/request/UserRequest.java new file mode 100644 index 0000000..4932b3f --- /dev/null +++ b/src/main/java/corecord/dev/domain/user/domain/dto/request/UserRequest.java @@ -0,0 +1,20 @@ +package corecord.dev.domain.user.domain.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +public class UserRequest { + @Data + public static class UserRegisterDto { + @NotBlank(message = "닉네임을 입력해주세요.") + private String nickName; + @NotBlank(message = "신분 상태를 입력해주세요.") + private String status; + } + + @Data + public static class UserUpdateDto { + private String nickName; + private String status; + } +} diff --git a/src/main/java/corecord/dev/domain/user/domain/dto/response/UserResponse.java b/src/main/java/corecord/dev/domain/user/domain/dto/response/UserResponse.java new file mode 100644 index 0000000..6da7068 --- /dev/null +++ b/src/main/java/corecord/dev/domain/user/domain/dto/response/UserResponse.java @@ -0,0 +1,26 @@ +package corecord.dev.domain.user.domain.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +public class UserResponse { + + @Data + @Builder + @AllArgsConstructor + public static class UserDto { + private Long userId; + private String nickname; + private String status; + } + + @Data + @Builder + @AllArgsConstructor + public static class UserInfoDto { + private int recordCount; + private String nickname; + private String status; + } +} diff --git a/src/main/java/corecord/dev/domain/user/domain/entity/Status.java b/src/main/java/corecord/dev/domain/user/domain/entity/Status.java new file mode 100644 index 0000000..4a72583 --- /dev/null +++ b/src/main/java/corecord/dev/domain/user/domain/entity/Status.java @@ -0,0 +1,30 @@ +package corecord.dev.domain.user.domain.entity; + +import corecord.dev.domain.user.status.UserErrorStatus; +import corecord.dev.domain.user.exception.UserException; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum Status { + UNIVERSITY_STUDENT("대학생"), + GRADUATE_STUDENT("대학원생"), + JOB_SEEKER("취업 준비생"), + INTERN("인턴"), + EMPLOYED("재직 중"), + OTHER("기타"); + + private final String value; + + public String getValue() { + return value; + } + + public static Status getStatus(String value) { + for (Status status : values()) { + if (status.getValue().equals(value)) { + return status; + } + } + throw new UserException(UserErrorStatus.INVALID_USER_STATUS); + } +} diff --git a/src/main/java/corecord/dev/domain/user/domain/entity/User.java b/src/main/java/corecord/dev/domain/user/domain/entity/User.java new file mode 100644 index 0000000..bff0235 --- /dev/null +++ b/src/main/java/corecord/dev/domain/user/domain/entity/User.java @@ -0,0 +1,69 @@ +package corecord.dev.domain.user.domain.entity; + +import corecord.dev.common.base.BaseEntity; +import corecord.dev.domain.ability.domain.entity.Ability; +import corecord.dev.domain.chat.domain.entity.ChatRoom; +import corecord.dev.domain.folder.domain.entity.Folder; +import corecord.dev.domain.record.domain.entity.Record; +import jakarta.persistence.*; +import lombok.*; + +import java.util.List; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class User extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(nullable = false) + private Long userId; + + @Column(nullable = false) + private String providerId; + + @Setter + @Column(nullable = false) + private String nickName; + + @Setter + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Status status; + + @Column + private Long tmpChat; + + @Column + private Long tmpMemo; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List records; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List chatRooms; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List abilities; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List folders; + + public void updateTmpMemo(Long tmpMemo) { + this.tmpMemo = tmpMemo; + } + + public void updateTmpChat(Long tmpChat) { + this.tmpChat = tmpChat; + } + + public void deleteTmpMemo(){ + this.tmpMemo = null; + } + + public void deleteTmpChat(){ + this.tmpChat = null; + } +} diff --git a/src/main/java/corecord/dev/domain/user/domain/repository/UserRepository.java b/src/main/java/corecord/dev/domain/user/domain/repository/UserRepository.java new file mode 100644 index 0000000..b1c1e80 --- /dev/null +++ b/src/main/java/corecord/dev/domain/user/domain/repository/UserRepository.java @@ -0,0 +1,21 @@ +package corecord.dev.domain.user.domain.repository; + +import corecord.dev.domain.user.domain.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByProviderId(String providerId); + boolean existsByProviderId(String providerId); + + @Modifying + @Query("DELETE FROM User u " + + "WHERE u.userId = :userId") + void deleteUserByUserId(@Param(value = "userId") Long userId); +} diff --git a/src/main/java/corecord/dev/domain/user/exception/UserException.java b/src/main/java/corecord/dev/domain/user/exception/UserException.java new file mode 100644 index 0000000..52e6e39 --- /dev/null +++ b/src/main/java/corecord/dev/domain/user/exception/UserException.java @@ -0,0 +1,16 @@ +package corecord.dev.domain.user.exception; + +import corecord.dev.domain.user.status.UserErrorStatus; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class UserException extends RuntimeException { + private final UserErrorStatus userErrorStatus; + + @Override + public String getMessage() { + return userErrorStatus.getMessage(); + } +} diff --git a/src/main/java/corecord/dev/domain/user/presentation/UserController.java b/src/main/java/corecord/dev/domain/user/presentation/UserController.java new file mode 100644 index 0000000..b58ca2c --- /dev/null +++ b/src/main/java/corecord/dev/domain/user/presentation/UserController.java @@ -0,0 +1,74 @@ +package corecord.dev.domain.user.presentation; + +import corecord.dev.common.response.ApiResponse; +import corecord.dev.common.status.SuccessStatus; +import corecord.dev.common.web.UserId; +import corecord.dev.domain.user.status.UserSuccessStatus; +import corecord.dev.domain.user.domain.dto.request.UserRequest; +import corecord.dev.domain.user.domain.dto.response.UserResponse; +import corecord.dev.domain.user.application.UserService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/users") +public class UserController { + private final UserService userService; + + @GetMapping("/test") + public ResponseEntity> getSuccess( + @UserId Long userId + ) { + return ApiResponse.success(SuccessStatus.OK, "userId: " + userId); + } + + @PostMapping("/register") + public ResponseEntity> registerUser( + HttpServletResponse response, + @RequestHeader("registerToken") String registerToken, + @RequestBody UserRequest.UserRegisterDto userRegisterDto + ) { + UserResponse.UserDto registerResponse = userService.registerUser(response, registerToken, userRegisterDto); + return ApiResponse.success(UserSuccessStatus.USER_REGISTER_SUCCESS, registerResponse); + } + + @PostMapping("/logout") + public ResponseEntity> logoutUser( + HttpServletRequest request, + HttpServletResponse response + ) { + userService.logoutUser(request, response); + return ApiResponse.success(UserSuccessStatus.USER_LOGOUT_SUCCESS); + } + + @DeleteMapping + public ResponseEntity> deleteUser( + HttpServletRequest request, + HttpServletResponse response, + @UserId Long userId + ) { + userService.deleteUser(request, response, userId); + return ApiResponse.success(UserSuccessStatus.USER_DELETE_SUCCESS); + } + + @PatchMapping + public ResponseEntity> updateUser( + @UserId Long userId, + @RequestBody UserRequest.UserUpdateDto userUpdateDto + ) { + userService.updateUser(userId, userUpdateDto); + return ApiResponse.success(UserSuccessStatus.USER_UPDATE_SUCCESS); + } + + @GetMapping + public ResponseEntity> getUserInfo( + @UserId Long userId + ) { + UserResponse.UserInfoDto userInfoDto = userService.getUserInfo(userId); + return ApiResponse.success(UserSuccessStatus.GET_USER_INFO_SUCCESS, userInfoDto); + } +} diff --git a/src/main/java/corecord/dev/domain/user/status/UserErrorStatus.java b/src/main/java/corecord/dev/domain/user/status/UserErrorStatus.java new file mode 100644 index 0000000..a92818a --- /dev/null +++ b/src/main/java/corecord/dev/domain/user/status/UserErrorStatus.java @@ -0,0 +1,18 @@ +package corecord.dev.domain.user.status; + +import corecord.dev.common.base.BaseErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum UserErrorStatus implements BaseErrorStatus { + INVALID_USER_NICKNAME(HttpStatus.BAD_REQUEST, "E101_NICKNAME", "유효하지 않은 닉네임입니다."), + INVALID_USER_STATUS(HttpStatus.BAD_REQUEST, "E101_STATUS", "신분상태의 입력이 잘못되었습니다."), + ALREADY_EXIST_USER(HttpStatus.BAD_REQUEST, "E101_EXIST_USER", "이미 존재하는 유저입니다."),; + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/corecord/dev/domain/user/status/UserSuccessStatus.java b/src/main/java/corecord/dev/domain/user/status/UserSuccessStatus.java new file mode 100644 index 0000000..95ec517 --- /dev/null +++ b/src/main/java/corecord/dev/domain/user/status/UserSuccessStatus.java @@ -0,0 +1,21 @@ +package corecord.dev.domain.user.status; + +import corecord.dev.common.base.BaseSuccessStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum UserSuccessStatus implements BaseSuccessStatus { + + USER_REGISTER_SUCCESS(HttpStatus.CREATED, "S101", "회원가입이 성공적으로 완료되었습니다."), + USER_LOGOUT_SUCCESS(HttpStatus.OK, "S802", "로그아웃이 성공적으로 완료되었습니다."), + USER_DELETE_SUCCESS(HttpStatus.OK, "S804", "회원탈퇴가 성공적으로 완료되었습니다."), + USER_UPDATE_SUCCESS(HttpStatus.OK, "S803", "회원정보 수정이 성공적으로 완료되었습니다."), + GET_USER_INFO_SUCCESS(HttpStatus.OK, "S801", "회원정보 조회가 성공적으로 완료되었습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..6a318e0 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,17 @@ +spring: + config: + import: application-secret.yml + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + jdbc: + time_zone: Asia/Seoul + show_sql: true + highlight_sql : true + +logging: + level: + org.springframework.web: DEBUG + org.springframework.web.client.DefaultRestClient: OFF \ No newline at end of file diff --git a/src/test/java/corecord/dev/DevApplicationTests.java b/src/test/java/corecord/dev/DevApplicationTests.java new file mode 100644 index 0000000..18f9939 --- /dev/null +++ b/src/test/java/corecord/dev/DevApplicationTests.java @@ -0,0 +1,13 @@ +package corecord.dev; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class DevApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/corecord/dev/ability/service/AbilityServiceTest.java b/src/test/java/corecord/dev/ability/service/AbilityServiceTest.java new file mode 100644 index 0000000..3d61881 --- /dev/null +++ b/src/test/java/corecord/dev/ability/service/AbilityServiceTest.java @@ -0,0 +1,186 @@ +package corecord.dev.ability.service; + +import corecord.dev.domain.ability.domain.dto.response.AbilityResponse; +import corecord.dev.domain.ability.domain.entity.Ability; +import corecord.dev.domain.ability.domain.entity.Keyword; +import corecord.dev.domain.ability.status.AbilityErrorStatus; +import corecord.dev.domain.ability.exception.AbilityException; +import corecord.dev.domain.ability.domain.repository.AbilityRepository; +import corecord.dev.domain.ability.application.AbilityService; +import corecord.dev.domain.analysis.domain.entity.Analysis; +import corecord.dev.domain.folder.domain.entity.Folder; +import corecord.dev.domain.record.domain.entity.RecordType; +import corecord.dev.domain.record.domain.entity.Record; +import corecord.dev.domain.user.domain.entity.Status; +import corecord.dev.domain.user.domain.entity.User; +import corecord.dev.domain.user.domain.repository.UserRepository; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class AbilityServiceTest { + + @Mock + EntityManager entityManager; + + @Mock + UserRepository userRepository; + + @Mock + AbilityRepository abilityRepository; + + @InjectMocks + AbilityService abilityService; + + private User user; + private Folder folder; + private Record record; + private Analysis analysis; + private String testKeywordComment = "Test Keyword Comment"; + + @BeforeEach + void setUp() { + user = createMockUser(); + folder = createMockFolder(user); + record = createMockRecord(user, folder); + analysis = createMockAnalysis(record); + analysis.setCreatedAt(LocalDateTime.now()); + } + + @Test + @DisplayName("유저의 경험 키워드 리스트 조회 테스트") + void getKeywordListTest() { + // Given + Ability ability1 = createMockAbility(Keyword.COMMUNICATION, analysis); + Ability ability2 = createMockAbility(Keyword.LEADERSHIP, analysis); + analysis.addAbility(ability1); + analysis.addAbility(ability2); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(abilityRepository.getKeywordList(any(User.class))) + .thenReturn(List.of(Keyword.COMMUNICATION, Keyword.LEADERSHIP)); + + // When + AbilityResponse.KeywordListDto response = abilityService.getKeywordList(1L); + + // Then + verify(userRepository, times(1)).findById(1L); + verify(abilityRepository, times(1)).getKeywordList(user); + + assertEquals(2, response.getKeywordList().size()); + assertEquals(Keyword.COMMUNICATION.getValue(), response.getKeywordList().get(0)); + assertEquals(Keyword.LEADERSHIP.getValue(), response.getKeywordList().get(1)); + } + + @Test + @DisplayName("경험 키워드 파싱 테스트") + void parseAndSaveAbilitiesTest() { + // Given + Map keywordList = Map.of( + Keyword.COMMUNICATION.getValue(), testKeywordComment, + Keyword.LEADERSHIP.getValue(), testKeywordComment); + + // When + abilityService.parseAndSaveAbilities(keywordList, analysis, user); + + // Then + verify(abilityRepository, times(2)).save(any(Ability.class)); + assertEquals(2, analysis.getAbilityList().size()); + assertEquals(Keyword.COMMUNICATION.getValue(), analysis.getAbilityList().get(0).getKeyword().getValue()); + assertEquals(Keyword.LEADERSHIP.getValue(), analysis.getAbilityList().get(1).getKeyword().getValue()); + } + + @Test + @DisplayName("경험 키워드 파싱 결과의 개수가 0인 경우 테스트") + void parseAbilityWithEmptyKeywordList() { + // Given + Map keywordList = Map.of("Keyword", testKeywordComment); + + // When & Then + AbilityException exception = assertThrows(AbilityException.class, + () -> abilityService.parseAndSaveAbilities(keywordList, analysis, user)); + assertEquals(exception.getAbilityErrorStatus(), AbilityErrorStatus.INVALID_ABILITY_KEYWORD); + } + + @Test + void deleteOriginAbilityTest() { + // Given + Ability ability1 = createMockAbility(Keyword.COMMUNICATION, analysis); + Ability ability2 = createMockAbility(Keyword.LEADERSHIP, analysis); + analysis.addAbility(ability1); + analysis.addAbility(ability2); + + assertEquals(2, analysis.getAbilityList().size()); + + // When + abilityService.deleteOriginAbilityList(analysis); + + // Then + assertEquals(0, analysis.getAbilityList().size()); + } + + + private User createMockUser() { + return User.builder() + .userId(1L) + .providerId("Test Provider") + .nickName("Test User") + .status(Status.GRADUATE_STUDENT) + .folders(new ArrayList<>()) + .build(); + } + + private Folder createMockFolder(User user) { + return Folder.builder() + .folderId(1L) + .title("Test Folder") + .user(user) + .build(); + } + + private Record createMockRecord(User user, Folder folder) { + return Record.builder() + .recordId(1L) + .title("Test Record") + .content("Test".repeat(10)) + .user(user) + .type(RecordType.MEMO) + .folder(folder) + .build(); + } + + private Analysis createMockAnalysis(Record record) { + return Analysis.builder() + .analysisId(1L) + .content("Test".repeat(10)) + .comment("Test Comment") + .record(record) + .abilityList(new ArrayList<>()) + .build(); + } + + private Ability createMockAbility(Keyword keyword, Analysis analysis) { + return Ability.builder() + .keyword(keyword) + .content("Test Keyword Content") + .user(user) + .analysis(analysis) + .build(); + } +} diff --git a/src/test/java/corecord/dev/analysis/service/AnalysisServiceTest.java b/src/test/java/corecord/dev/analysis/service/AnalysisServiceTest.java new file mode 100644 index 0000000..89d3bd4 --- /dev/null +++ b/src/test/java/corecord/dev/analysis/service/AnalysisServiceTest.java @@ -0,0 +1,293 @@ +package corecord.dev.analysis.service; + +import corecord.dev.domain.ability.domain.entity.Ability; +import corecord.dev.domain.ability.domain.entity.Keyword; +import corecord.dev.domain.ability.status.AbilityErrorStatus; +import corecord.dev.domain.ability.exception.AbilityException; +import corecord.dev.domain.ability.application.AbilityService; +import corecord.dev.domain.analysis.domain.dto.request.AnalysisRequest; +import corecord.dev.domain.analysis.infra.openai.dto.response.AnalysisAiResponse; +import corecord.dev.domain.analysis.domain.dto.response.AnalysisResponse; +import corecord.dev.domain.analysis.domain.entity.Analysis; +import corecord.dev.domain.analysis.status.AnalysisErrorStatus; +import corecord.dev.domain.analysis.exception.AnalysisException; +import corecord.dev.domain.analysis.domain.repository.AnalysisRepository; +import corecord.dev.domain.analysis.application.AnalysisService; +import corecord.dev.domain.analysis.infra.openai.application.OpenAiService; +import corecord.dev.domain.folder.domain.entity.Folder; +import corecord.dev.domain.record.domain.entity.RecordType; +import corecord.dev.domain.record.domain.entity.Record; +import corecord.dev.domain.user.domain.entity.Status; +import corecord.dev.domain.user.domain.entity.User; +import corecord.dev.domain.user.domain.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) + +public class AnalysisServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private AnalysisRepository analysisRepository; + + @Mock + private OpenAiService openAiService; + + @Mock + private AbilityService abilityService; + + @InjectMocks + private AnalysisService analysisService; + + private User user; + private Folder folder; + private Record record; + private Analysis analysis; + + private String testTitle = "Test Record"; + private String testContent = "Test".repeat(10); + private String testComment = "Test Comment"; + + @BeforeEach + void setUp() { + user = createMockUser(); + folder = createMockFolder(user); + record = createMockRecord(user, folder); + analysis = createMockAnalysis(record); + analysis.setCreatedAt(LocalDateTime.now()); + } + + @Test + @DisplayName("메모 역량 분석 생성 테스트") + void createMemoAnalysisTest() { + // Given + when(openAiService.generateMemoSummary(any(String.class))).thenReturn(testContent); + when(openAiService.generateAbilityAnalysis(any(String.class))) + .thenReturn(new AnalysisAiResponse(Map.of("커뮤니케이션", "Test Keyword Content"), "Test Comment")); + when(analysisRepository.save(any(Analysis.class))).thenReturn(analysis); + doNothing().when(abilityService).parseAndSaveAbilities(any(Map.class), any(Analysis.class), any(User.class)); + + // When + Analysis response = analysisService.createAnalysis(record, user); + + // Then + verify(openAiService).generateMemoSummary(testContent); + verify(openAiService).generateAbilityAnalysis(testContent); + verify(analysisRepository).save(any(Analysis.class)); + + assertEquals(response.getContent(), testContent); + assertEquals(response.getComment(), testComment); + } + + @Test + @DisplayName("메모 역량 분석 요약 글자수 예외 발생 테스트") + void createMemoAnalysisWithNotEnoughContentTest() { + // Given + String overContent = "Test".repeat(500); + when(openAiService.generateMemoSummary(any(String.class))).thenReturn(overContent); + + // When & Then + AnalysisException exception = assertThrows(AnalysisException.class, + () -> analysisService.createAnalysis(record, user)); + assertEquals(exception.getAnalysisErrorStatus(), AnalysisErrorStatus.OVERFLOW_ANALYSIS_CONTENT); + } + + @Test + @DisplayName("메모 역량 분석 코멘트 글자수 예외 발생 테스트") + void createMemoAnalysisWithNotEnoughCommentTest() { + // Given + String overComment = "Test".repeat(200); + when(openAiService.generateMemoSummary(any(String.class))).thenReturn(testContent); + when(openAiService.generateAbilityAnalysis(any(String.class))) + .thenReturn(new AnalysisAiResponse(Map.of("커뮤니케이션", "Test Keyword Content"), overComment)); + + // When & Then + AnalysisException exception = assertThrows(AnalysisException.class, + () -> analysisService.createAnalysis(record, user)); + assertEquals(exception.getAnalysisErrorStatus(), AnalysisErrorStatus.OVERFLOW_ANALYSIS_COMMENT); + } + + @Test + @DisplayName("메모 역량 분석 키워드 글자수 예외 발생 테스트") + void createMemoAnalysisWithLongKeywordCommentTest() { + // Given + String overKeywordComment = "Test".repeat(200); + when(openAiService.generateMemoSummary(any(String.class))).thenReturn(testContent); + when(openAiService.generateAbilityAnalysis(any(String.class))) + .thenReturn(new AnalysisAiResponse(Map.of("커뮤니케이션", overKeywordComment), testComment)); + + // When & Then + AnalysisException exception = assertThrows(AnalysisException.class, + () -> analysisService.createAnalysis(record, user)); + assertEquals(exception.getAnalysisErrorStatus(), AnalysisErrorStatus.OVERFLOW_ANALYSIS_KEYWORD_CONTENT); + } + + @Test + @DisplayName("역량 분석 수정 테스트") + void updateAnalysisTest() { + // Given + Ability ability = createMockAbility(analysis); + analysis.addAbility(ability); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(analysisRepository.findAnalysisById(1L)).thenReturn(Optional.of(analysis)); + + // When + AnalysisRequest.AnalysisUpdateDto request = AnalysisRequest.AnalysisUpdateDto.builder() + .analysisId(1L) + .title("Updated Title") + .content("Updated Content".repeat(5)) + .abilityMap(Map.of("커뮤니케이션", "Updated Keyword Content")) + .build(); + + AnalysisResponse.AnalysisDto response = analysisService.updateAnalysis(1L, request); + + // Then + verify(userRepository, times(1)).findById(1L); + verify(analysisRepository, times(1)).findAnalysisById(1L); + + assertEquals(response.getAnalysisId(), analysis.getAnalysisId()); + assertEquals(response.getRecordId(), record.getRecordId()); + assertEquals(response.getRecordTitle(), "Updated Title"); + assertEquals(response.getRecordContent(), "Updated Content".repeat(5)); + assertEquals(response.getComment(), analysis.getComment()); + assertEquals(response.getAbilityDtoList().get(0).getKeyword(), "커뮤니케이션"); + assertEquals(response.getAbilityDtoList().get(0).getContent(), "Updated Keyword Content"); + } + + @Test + @DisplayName("역량 분석 수정 중 존재하지 않는 키워드 제시 시 예외 발생 테스트") + void findAbilityByNotExistingKeyword() { + // Given + Ability ability = createMockAbility(analysis); + analysis.addAbility(ability); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(analysisRepository.findAnalysisById(1L)).thenReturn(Optional.of(analysis)); + + // When & Then + AnalysisRequest.AnalysisUpdateDto request = AnalysisRequest.AnalysisUpdateDto.builder() + .analysisId(1L) + .abilityMap(Map.of("협동", testContent)) + .build(); + + AbilityException exception = assertThrows(AbilityException.class, + () -> analysisService.updateAnalysis(1L, request)); + + assertEquals(exception.getAbilityErrorStatus(), AbilityErrorStatus.INVALID_KEYWORD); + verify(userRepository, times(1)).findById(1L); + verify(analysisRepository, times(1)).findAnalysisById(1L); + } + + @Test + @DisplayName("역량 분석 상세 정보 조회 테스트") + void getAnalysisDetailTest() { + // Given + Ability ability = createMockAbility(analysis); + analysis.addAbility(ability); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(analysisRepository.findAnalysisById(1L)).thenReturn(Optional.of(analysis)); + + // When + AnalysisResponse.AnalysisDto response = analysisService.getAnalysis(1L, 1L); + + // Then + verify(userRepository, times(1)).findById(1L); + verify(analysisRepository, times(1)).findAnalysisById(1L); + + assertEquals(response.getAnalysisId(), analysis.getAnalysisId()); + assertEquals(response.getRecordId(), record.getRecordId()); + assertEquals(response.getRecordTitle(), testTitle); + assertEquals(response.getRecordContent(), testContent); + assertEquals(response.getComment(), testComment); + assertEquals(response.getAbilityDtoList().get(0).getKeyword(), "커뮤니케이션"); + assertEquals(response.getAbilityDtoList().get(0).getContent(), "Test Keyword Content"); + } + + @Test + @DisplayName("역량 분석 삭제 테스트") + void deleteAnalysisTest() { + // Given + Ability ability = createMockAbility(analysis); + analysis.addAbility(ability); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(analysisRepository.findAnalysisById(1L)).thenReturn(Optional.of(analysis)); + + // When + analysisService.deleteAnalysis(1L, 1L); + + // Then + verify(userRepository, times(1)).findById(1L); + verify(analysisRepository, times(1)).findAnalysisById(1L); + verify(analysisRepository).delete(analysis); + } + + private User createMockUser() { + return User.builder() + .userId(1L) + .providerId("Test Provider") + .nickName("Test User") + .status(Status.GRADUATE_STUDENT) + .folders(new ArrayList<>()) + .build(); + } + + private Folder createMockFolder(User user) { + return Folder.builder() + .folderId(1L) + .title("Test Folder") + .user(user) + .build(); + } + + private Record createMockRecord(User user, Folder folder) { + return Record.builder() + .recordId(1L) + .title(testTitle) + .content(testContent) + .user(user) + .type(RecordType.MEMO) + .folder(folder) + .build(); + } + + private Analysis createMockAnalysis(Record record) { + return Analysis.builder() + .analysisId(1L) + .content(testContent) + .comment(testComment) + .record(record) + .abilityList(new ArrayList<>()) + .build(); + } + + private Ability createMockAbility(Analysis analysis) { + return Ability.builder() + .keyword(Keyword.COMMUNICATION) + .content("Test Keyword Content") + .user(user) + .analysis(analysis) + .build(); + } + +} diff --git a/src/test/java/corecord/dev/auth/util/JwtUtilTest.java b/src/test/java/corecord/dev/auth/util/JwtUtilTest.java new file mode 100644 index 0000000..41434a4 --- /dev/null +++ b/src/test/java/corecord/dev/auth/util/JwtUtilTest.java @@ -0,0 +1,148 @@ +package corecord.dev.auth.util; + +import corecord.dev.domain.auth.jwt.JwtUtil; +import corecord.dev.domain.auth.status.TokenErrorStatus; +import corecord.dev.domain.auth.exception.TokenException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import javax.crypto.SecretKey; + +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class JwtUtilTest { + + private JwtUtil jwtUtil; + private final String SECRET_KEY = "testsecretkeytestsecretkeytestsecretkeytestsecretkeytestsecretkeytestsecretkeytestsecretkeytestsecretkey"; + private final long REGISTER_TOKEN_EXPIRE_TIME = 1000 * 60 * 60; // 1 hour + private final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24; // 24 hours + private final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; // 7 days + private SecretKey key; + private Long userId; + private String providerId; + + @BeforeEach + void setUp() { + userId = 1L; + providerId = "testProvider"; + jwtUtil = new JwtUtil(); + ReflectionTestUtils.setField(jwtUtil, "SECRET_KEY", SECRET_KEY); + ReflectionTestUtils.setField(jwtUtil, "REGISTER_TOKEN_EXPIRATION_TIME", REGISTER_TOKEN_EXPIRE_TIME); + ReflectionTestUtils.setField(jwtUtil, "ACCESS_TOKEN_EXPIRATION_TIME", ACCESS_TOKEN_EXPIRE_TIME); + ReflectionTestUtils.setField(jwtUtil, "REFRESH_TOKEN_EXPIRATION_TIME", REFRESH_TOKEN_EXPIRE_TIME); + key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET_KEY)); + } + + @Test + @DisplayName("액세스 토큰 생성 및 유효성 검사") + void generateAndValidateAccessToken() { + // when + String accessToken = jwtUtil.generateAccessToken(userId); + + // then + assertThat(accessToken).isNotNull().isNotEmpty(); + assertThat(accessToken.split("\\.")).hasSize(3); + + Claims payload = Jwts.parser() + .setSigningKey(key) + .build() + .parseClaimsJws(accessToken) + .getBody(); + + assertThat(payload.get("userId", String.class)).isEqualTo(userId.toString()); + } + + @Test + @DisplayName("리프레쉬 토큰 생성 및 유효성 검사") + void generateAndValidateRefreshToken() { + // when + String refreshToken = jwtUtil.generateRefreshToken(userId); + + // then + assertThat(refreshToken).isNotNull().isNotEmpty(); + assertThat(refreshToken.split("\\.")).hasSize(3); + + Claims payload = Jwts.parser() + .setSigningKey(key) + .build() + .parseClaimsJws(refreshToken) + .getBody(); + + assertThat(payload.get("userId", String.class)).isEqualTo(userId.toString()); + } + + @Test + @DisplayName("레지스터 토큰 생성 및 유효성 검사") + void generateAndValidateRegisterToken() { + // when + String registerToken = jwtUtil.generateRegisterToken(providerId); + + // then + assertThat(registerToken).isNotNull().isNotEmpty(); + assertThat(registerToken.split("\\.")).hasSize(3); + + Claims payload = Jwts.parser() + .setSigningKey(key) + .build() + .parseClaimsJws(registerToken) + .getBody(); + + assertThat(payload.get("providerId", String.class)).isEqualTo(providerId); + } + + @Test + @DisplayName("임시 토큰 생성 및 유효성 검사") + void generateAndValidateTmpToken() { + // when + String tmpToken = jwtUtil.generateTmpToken(userId); + + // then + assertThat(tmpToken).isNotNull().isNotEmpty(); + assertThat(tmpToken.split("\\.")).hasSize(3); + + Claims payload = Jwts.parser() + .setSigningKey(key) + .build() + .parseClaimsJws(tmpToken) + .getBody(); + + assertThat(payload.get("userId", String.class)).isEqualTo(userId.toString()); + } + + @Test + @DisplayName("만료된 액세스 토큰 예외 발생") + void expiredAccessTokenThrowsException() { + // given + String expiredAccessToken = Jwts.builder() + .setSubject(userId.toString()) + .setExpiration(new Date(System.currentTimeMillis() - 1000)) // 이미 만료된 시간 설정 + .signWith(SignatureAlgorithm.HS256, key) + .compact(); + + // then + TokenException exception = assertThrows(TokenException.class, () -> jwtUtil.isAccessTokenValid(expiredAccessToken)); + assertThat(exception.getTokenErrorStatus()).isEqualTo(TokenErrorStatus.INVALID_ACCESS_TOKEN); + } + + + @Test + @DisplayName("유효하지 않은 토큰 예외 발생") + void invalidTokenThrowsException() { + // given + String invalidToken = "invalid.token"; + + // then + TokenException exception = assertThrows(TokenException.class, () -> jwtUtil.isAccessTokenValid(invalidToken)); + assertThat(exception.getTokenErrorStatus()).isEqualTo(TokenErrorStatus.INVALID_ACCESS_TOKEN); + } +} diff --git a/src/test/java/corecord/dev/chat/repository/ChatRepositoryTest.java b/src/test/java/corecord/dev/chat/repository/ChatRepositoryTest.java new file mode 100644 index 0000000..0fac57f --- /dev/null +++ b/src/test/java/corecord/dev/chat/repository/ChatRepositoryTest.java @@ -0,0 +1,109 @@ +package corecord.dev.chat.repository; + +import corecord.dev.domain.chat.domain.entity.Chat; +import corecord.dev.domain.chat.domain.entity.ChatRoom; +import corecord.dev.domain.chat.domain.repository.ChatRepository; +import corecord.dev.domain.chat.domain.repository.ChatRoomRepository; +import corecord.dev.domain.user.domain.entity.Status; +import corecord.dev.domain.user.domain.entity.User; +import corecord.dev.domain.user.domain.repository.UserRepository; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest +@Transactional +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class ChatRepositoryTest { + + @Autowired + ChatRepository chatRepository; + + @Autowired + ChatRoomRepository chatRoomRepository; + + @Autowired + UserRepository userRepository; + + @Autowired + EntityManager entityManager; + + @Test + @DisplayName("채팅방 ID로 채팅 삭제 테스트") + void deleteByChatRoomId() { + // Given + User user = createTestUser(); + ChatRoom chatRoom = createTestChatRoom(user); + + createTestChat(chatRoom, "First message"); + createTestChat(chatRoom, "Second message"); + + List chatsBeforeDelete = chatRepository.findByChatRoomOrderByChatId(chatRoom); + assertEquals(2, chatsBeforeDelete.size()); + + // When + chatRepository.deleteByChatRoomId(chatRoom.getChatRoomId()); + entityManager.flush(); + + // Then + List chatsAfterDelete = chatRepository.findByChatRoomOrderByChatId(chatRoom); + assertTrue(chatsAfterDelete.isEmpty()); + } + + @Test + @DisplayName("채팅방에 속한 채팅 조회 테스트") + void findByChatRoomOrderByChatId() { + // Given + User user = createTestUser(); + ChatRoom chatRoom = createTestChatRoom(user); + + createTestChat(chatRoom, "First message"); + createTestChat(chatRoom, "Second message"); + createTestChat(chatRoom, "Third message"); + + // When + List chats = chatRepository.findByChatRoomOrderByChatId(chatRoom); + + // Then + assertEquals(3, chats.size()); + assertEquals("First message", chats.get(0).getContent()); + assertEquals("Second message", chats.get(1).getContent()); + assertEquals("Third message", chats.get(2).getContent()); + } + + private User createTestUser() { + // 사용자 저장 시 persist 호출 + User user = User.builder() + .providerId("testProvider") + .nickName("TestUser") + .status(Status.UNIVERSITY_STUDENT) + .build(); + userRepository.save(user); + return user; + } + + private ChatRoom createTestChatRoom(User user) { + ChatRoom chatRoom = ChatRoom.builder() + .user(user) + .build(); + chatRoomRepository.save(chatRoom); + return chatRoom; + } + + private void createTestChat(ChatRoom chatRoom, String content) { + Chat chat = Chat.builder() + .author(1) + .content(content) + .chatRoom(chatRoom) + .build(); + chatRepository.save(chat); + } +} diff --git a/src/test/java/corecord/dev/chat/repository/ChatRoomRepositoryTest.java b/src/test/java/corecord/dev/chat/repository/ChatRoomRepositoryTest.java new file mode 100644 index 0000000..eebabcf --- /dev/null +++ b/src/test/java/corecord/dev/chat/repository/ChatRoomRepositoryTest.java @@ -0,0 +1,77 @@ +package corecord.dev.chat.repository; + +import corecord.dev.domain.chat.domain.entity.ChatRoom; +import corecord.dev.domain.chat.domain.repository.ChatRoomRepository; +import corecord.dev.domain.user.domain.entity.Status; +import corecord.dev.domain.user.domain.entity.User; +import corecord.dev.domain.user.domain.repository.UserRepository; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest +@Transactional +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class ChatRoomRepositoryTest { + + @Autowired + ChatRoomRepository chatRoomRepository; + + @Autowired + UserRepository userRepository; + + @Autowired + EntityManager entityManager; + + @Test + @DisplayName("ChatRoom ID와 User로 채팅방 조회 테스트") + void findByChatRoomIdAndUser() { + // Given + User user = createTestUser(); + ChatRoom chatRoom = createTestChatRoom(user); + + // When + Optional foundChatRoom = chatRoomRepository.findByChatRoomIdAndUser(chatRoom.getChatRoomId(), user); + + // Then + assertTrue(foundChatRoom.isPresent()); + assertEquals(foundChatRoom.get().getUser(), user); + } + + @Test + @DisplayName("존재하지 않는 채팅방 조회 테스트") + void findByChatRoomIdAndUser_NotFound() { + // Given + User user = createTestUser(); + + // When + Optional foundChatRoom = chatRoomRepository.findByChatRoomIdAndUser(999L, user); + + // Then + assertFalse(foundChatRoom.isPresent()); + } + + private User createTestUser() { + User user = User.builder() + .providerId("testProvider") + .nickName("TestUser") + .status(Status.UNIVERSITY_STUDENT) + .build(); + return userRepository.save(user); + } + + private ChatRoom createTestChatRoom(User user) { + ChatRoom chatRoom = ChatRoom.builder() + .user(user) + .build(); + return chatRoomRepository.save(chatRoom); + } +} diff --git a/src/test/java/corecord/dev/chat/service/ChatServiceTest.java b/src/test/java/corecord/dev/chat/service/ChatServiceTest.java new file mode 100644 index 0000000..481e355 --- /dev/null +++ b/src/test/java/corecord/dev/chat/service/ChatServiceTest.java @@ -0,0 +1,325 @@ +package corecord.dev.chat.service; + +import corecord.dev.domain.chat.domain.dto.request.ChatRequest; +import corecord.dev.domain.chat.domain.dto.response.ChatResponse; +import corecord.dev.domain.chat.domain.entity.Chat; +import corecord.dev.domain.chat.domain.entity.ChatRoom; +import corecord.dev.domain.chat.exception.ChatException; +import corecord.dev.domain.chat.domain.repository.ChatRepository; +import corecord.dev.domain.chat.domain.repository.ChatRoomRepository; +import corecord.dev.domain.chat.application.ChatService; +import corecord.dev.domain.chat.infra.clova.dto.request.ClovaRequest; +import corecord.dev.domain.chat.infra.clova.application.ClovaService; +import corecord.dev.domain.user.domain.entity.Status; +import corecord.dev.domain.user.domain.entity.User; +import corecord.dev.domain.user.domain.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ChatServiceTest { + + @InjectMocks + private ChatService chatService; + + @Mock + private ChatRoomRepository chatRoomRepository; + + @Mock + private ChatRepository chatRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ClovaService clovaService; + + private User user; + + private ChatRoom chatRoom; + + @BeforeEach + void setUp() { + user = createTestUser(); + chatRoom = createTestChatRoom(); + + } + + @Test + @DisplayName("채팅방 생성 테스트") + void createChatRoom() { + // Given + when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); + + // When + ChatResponse.ChatRoomDto result = chatService.createChatRoom(user.getUserId()); + + // Then + verify(chatRoomRepository).save(any(ChatRoom.class)); + verify(chatRepository).save(any(Chat.class)); + assertEquals(result.getFirstChat(), "안녕하세요! testUser님\n오늘은 어떤 경험을 했나요?\n저와 함께 정리해보아요!"); + } + + @Test + @DisplayName("채팅 조회 테스트") + void getChatList() throws NoSuchFieldException, IllegalAccessException { + // Given + Chat userChat = createTestChat("userChat", 1); + Chat aiChat = createTestChat("aiChat", 0); + + + when(chatRepository.findByChatRoomOrderByChatId(chatRoom)).thenReturn(List.of(userChat, aiChat)); + when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); + when(chatRoomRepository.findByChatRoomIdAndUser(chatRoom.getChatRoomId(), user)).thenReturn(Optional.of(chatRoom)); + + // When + ChatResponse.ChatListDto result = chatService.getChatList(user.getUserId(), chatRoom.getChatRoomId()); + + // Then + assertEquals(result.getChats().size(), 2); + assertEquals(result.getChats().get(0).getContent(), "userChat"); + assertEquals(result.getChats().get(1).getContent(), "aiChat"); + } + + @Nested + @DisplayName("채팅 생성하기 테스트") + class ChatServiceAiResponseTests { + + @Test + @DisplayName("가이드 호출 시") + void createChatWithGuide() { + // Given + ChatRequest.ChatDto request = ChatRequest.ChatDto.builder() + .guide(true) + .content("어떤 경험을 말해야 할지 모르겠어요.") + .build(); + + when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); + when(chatRoomRepository.findByChatRoomIdAndUser(chatRoom.getChatRoomId(), user)).thenReturn(Optional.of(chatRoom)); + + // When + ChatResponse.ChatsDto result = chatService.createChat( + user.getUserId(), + chatRoom.getChatRoomId(), + request + ); + + // Then + verify(chatRepository, times(3)).save(any(Chat.class)); // 사용자 입력 1개, 가이드 2개 + assertEquals(result.getChats().size(), 2); // Guide 메시지는 두 개 생성 + assertEquals(result.getChats().get(0).getContent(), "걱정 마세요!\n저와 대화하다 보면 경험이 정리될 거예요\uD83D\uDCDD"); + assertEquals(result.getChats().get(1).getContent(), "오늘은 어떤 경험을 했나요?\n상황과 해결한 문제를 말해주세요!"); + } + + @Test + @DisplayName("AI 응답 성공 시") + void createChatWithSuccess() { + // Given + ChatRequest.ChatDto request = ChatRequest.ChatDto.builder() + .content("테스트 입력") + .build(); + + when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); + when(chatRoomRepository.findByChatRoomIdAndUser(chatRoom.getChatRoomId(), user)).thenReturn(Optional.of(chatRoom)); + when(chatRepository.save(any(Chat.class))).thenAnswer(invocation -> invocation.getArgument(0)); // 저장된 Chat 객체 반환 + when(clovaService.generateAiResponse(any(ClovaRequest.class))).thenReturn("AI의 예상 응답"); + + // When + ChatResponse.ChatsDto result = chatService.createChat( + user.getUserId(), + chatRoom.getChatRoomId(), + request + ); + + // Then + verify(chatRepository, times(2)).save(any(Chat.class)); // 사용자 입력 1개, AI 응답 1개 + assertEquals(result.getChats().size(), 1); + assertEquals(result.getChats().getFirst().getContent(), "AI의 예상 응답"); + } + } + + @Nested + @DisplayName("채팅 요약 정보 생성 테스트") + class ChatSummaryTests { + + @Test + @DisplayName("AI 응답 성공 시") + void validAiResponse() throws NoSuchFieldException, IllegalAccessException { + // Given + List chatList = List.of( + createTestChat("userChat1", 1), + createTestChat("aiChat1", 0), + createTestChat("userChat2", 1) + ); + + when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); + when(chatRoomRepository.findByChatRoomIdAndUser(chatRoom.getChatRoomId(), user)).thenReturn(Optional.of(chatRoom)); + when(chatRepository.findByChatRoomOrderByChatId(chatRoom)).thenReturn(chatList); + when(clovaService.generateAiResponse(any(ClovaRequest.class))) + .thenReturn("{\"title\":\"요약 제목\",\"content\":\"요약 내용\"}"); + + // When + ChatResponse.ChatSummaryDto result = chatService.getChatSummary(user.getUserId(), chatRoom.getChatRoomId()); + + // Then + assertEquals(result.getTitle(), "요약 제목"); + assertEquals(result.getContent(), "요약 내용"); + } + + @Test + @DisplayName("AI 응답이 빈 경우") + void emptyAiResponse() throws NoSuchFieldException, IllegalAccessException { + // Given + List chatList = List.of( + createTestChat("userChat1", 1), + createTestChat("aiChat1", 0) + ); + + when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); + when(chatRoomRepository.findByChatRoomIdAndUser(chatRoom.getChatRoomId(), user)).thenReturn(Optional.of(chatRoom)); + when(chatRepository.findByChatRoomOrderByChatId(chatRoom)).thenReturn(chatList); + when(clovaService.generateAiResponse(any(ClovaRequest.class))) + .thenReturn("{\"title\":\"\",\"content\":\"\"}"); // 빈 응답 + + // When & Then + assertThrows(ChatException.class, () -> chatService.getChatSummary(user.getUserId(), chatRoom.getChatRoomId())); + } + + @Test + @DisplayName("AI 응답이 긴 경우 예외 발생 (제목 50자 초과)") + void longAiTitle() throws NoSuchFieldException, IllegalAccessException { + // Given + List chatList = List.of( + createTestChat("userChat1", 1), + createTestChat("aiChat1", 0) + ); + + String longTitle = "a".repeat(51); // 51자 제목 생성 + when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); + when(chatRoomRepository.findByChatRoomIdAndUser(chatRoom.getChatRoomId(), user)).thenReturn(Optional.of(chatRoom)); + when(chatRepository.findByChatRoomOrderByChatId(chatRoom)).thenReturn(chatList); + when(clovaService.generateAiResponse(any(ClovaRequest.class))) + .thenReturn(String.format("{\"title\":\"%s\",\"content\":\"정상 내용\"}", longTitle)); // 50자 초과 제목 + + // When & Then + assertThrows(ChatException.class, () -> chatService.getChatSummary(user.getUserId(), chatRoom.getChatRoomId())); + } + + @Test + @DisplayName("AI 응답이 긴 경우 예외 발생 (내용 500자 초과)") + void longAiResponse() throws NoSuchFieldException, IllegalAccessException { + // Given + List chatList = List.of( + createTestChat("userChat1", 1), + createTestChat("aiChat1", 0) + ); + + String longContent = "a".repeat(501); // 501자 응답 생성 + when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); + when(chatRoomRepository.findByChatRoomIdAndUser(chatRoom.getChatRoomId(), user)).thenReturn(Optional.of(chatRoom)); + when(chatRepository.findByChatRoomOrderByChatId(chatRoom)).thenReturn(chatList); + when(clovaService.generateAiResponse(any(ClovaRequest.class))) + .thenReturn(String.format("{\"title\":\"정상 제목\",\"content\":\"%s\"}", longContent)); // 500자 초과 내용 + + // When & Then + assertThrows(ChatException.class, () -> chatService.getChatSummary(user.getUserId(), chatRoom.getChatRoomId())); + } + } + + @Nested + @DisplayName("임시 채팅방 테스트") + class ChatTmpTests { + + @Test + @DisplayName("저장 성공") + void saveChatTmp() { + // Given + when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); + when(chatRoomRepository.findByChatRoomIdAndUser(chatRoom.getChatRoomId(), user)).thenReturn(Optional.of(chatRoom)); + + // When + chatService.saveChatTmp(user.getUserId(), chatRoom.getChatRoomId()); + + // Then + assertEquals(user.getTmpChat(), chatRoom.getChatRoomId()); + verify(userRepository).findById(user.getUserId()); + } + + @Test + @DisplayName("이미 임시 저장된 채팅방이 있을 경우 예외 처리") + void saveChatTmpFailsWhenTmpChatExists() { + // Given + user.updateTmpChat(chatRoom.getChatRoomId()); + when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); + when(chatRoomRepository.findByChatRoomIdAndUser(chatRoom.getChatRoomId(), user)).thenReturn(Optional.of(chatRoom)); + + // When & Then + assertThrows(ChatException.class, () -> chatService.saveChatTmp(user.getUserId(), chatRoom.getChatRoomId())); + } + + @Test + @DisplayName("조회 성공") + void getChatTmp() { + // Given + user.updateTmpChat(chatRoom.getChatRoomId()); + when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); + + // When + ChatResponse.ChatTmpDto result = chatService.getChatTmp(user.getUserId()); + + // Then + assertEquals(result.getChatRoomId(), chatRoom.getChatRoomId()); + assertTrue(result.isExist()); + verify(userRepository).findById(user.getUserId()); + } + } + + private User createTestUser() { + return User.builder() + .userId(1L) + .providerId("providerId") + .nickName("testUser") + .status(Status.UNIVERSITY_STUDENT) + .abilities(new ArrayList<>()) + .chatRooms(new ArrayList<>()) + .folders(new ArrayList<>()) + .records(new ArrayList<>()) + .build(); + } + + private ChatRoom createTestChatRoom() { + return ChatRoom.builder() + .chatRoomId(1L) + .user(user) + .chatList(new ArrayList<>()) + .build(); + } + + private Chat createTestChat(String content, int isSystem) throws IllegalAccessException, NoSuchFieldException { + Chat chat = Chat.builder() + .chatId(1L) + .author(isSystem) + .content(content) + .chatRoom(chatRoom) + .build(); + Field createdAtField = Chat.class.getSuperclass().getDeclaredField("createdAt"); + createdAtField.setAccessible(true); + createdAtField.set(chat, LocalDateTime.now()); + return chat; + } +} diff --git a/src/test/java/corecord/dev/folder/repository/FolderRepositoryTest.java b/src/test/java/corecord/dev/folder/repository/FolderRepositoryTest.java new file mode 100644 index 0000000..d317289 --- /dev/null +++ b/src/test/java/corecord/dev/folder/repository/FolderRepositoryTest.java @@ -0,0 +1,86 @@ +package corecord.dev.folder.repository; + +import corecord.dev.domain.folder.domain.entity.Folder; +import corecord.dev.domain.folder.domain.repository.FolderRepository; +import corecord.dev.domain.user.domain.entity.Status; +import corecord.dev.domain.user.domain.entity.User; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.ArrayList; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class FolderRepositoryTest { + @Autowired + EntityManager entityManager; + @Autowired + FolderRepository folderRepository; + + @Test + void findFolderByTitle() { + String testTitle1 = "Test Title1"; + String testTitle2 = "Test Title2"; + + // Given + User user = createUser("Test User"); + entityManager.persist(user); + + Folder folder1 = createFolder(testTitle1, user); + entityManager.persist(folder1); + + Folder folder2 = createFolder(testTitle2, user); + entityManager.persist(folder2); + + Long testId = folder2.getFolderId(); + + // When + Optional result = folderRepository.findFolderByTitle(testTitle2, user); + + // Then + assertThat(result.isPresent()).isTrue(); + assertThat(result.get().getUser()).isEqualTo(user); + assertThat(result.get().getFolderId()).isEqualTo(testId); + assertThat(result.get().getTitle()).isEqualTo(testTitle2); + } + + @Test + void existByTitle() { + String testTitle = "Test Title"; + + // Given + User user = createUser("Test User"); + entityManager.persist(user); + + Folder folder1 = createFolder(testTitle, user); + entityManager.persist(folder1); + + // When + boolean result = folderRepository.existsByTitle(testTitle); + + // Then + assertThat(result).isEqualTo(true); + } + + private User createUser(String nickName) { + return User.builder() + .providerId("Test Provider") + .nickName(nickName) + .status(Status.GRADUATE_STUDENT) + .folders(new ArrayList<>()) + .build(); + } + + private Folder createFolder(String title, User user) { + return Folder.builder() + .title(title) + .user(user) + .build(); + } +} diff --git a/src/test/java/corecord/dev/folder/service/FolderServiceTest.java b/src/test/java/corecord/dev/folder/service/FolderServiceTest.java new file mode 100644 index 0000000..e927a41 --- /dev/null +++ b/src/test/java/corecord/dev/folder/service/FolderServiceTest.java @@ -0,0 +1,160 @@ +package corecord.dev.folder.service; + +import corecord.dev.domain.folder.domain.dto.request.FolderRequest; +import corecord.dev.domain.folder.domain.dto.response.FolderResponse; +import corecord.dev.domain.folder.domain.entity.Folder; +import corecord.dev.domain.folder.exception.FolderException; +import corecord.dev.domain.folder.domain.repository.FolderRepository; +import corecord.dev.domain.folder.application.FolderService; +import corecord.dev.domain.user.domain.entity.Status; +import corecord.dev.domain.user.domain.entity.User; +import corecord.dev.domain.user.domain.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +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.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class FolderServiceTest { + @Mock + private FolderRepository folderRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private FolderService folderService; + + private final Long testId = 1L; + private final String testTitle = "Test folder"; + + + @Test + @DisplayName("새로운 폴더 생성 기능") + void createFolder() { + // Given + User user = createMockUser(testId, "Test User"); + Folder folder = createMockFolder(testId, testTitle, user); + user.getFolders().add(folder); + + when(userRepository.findById(testId)).thenReturn(Optional.of(user)); + when(folderRepository.save(any(Folder.class))).thenReturn(folder); + when(folderRepository.findFolderDtoList(user)).thenReturn(List.of( + FolderResponse.FolderDto.builder() + .folderId(testId) + .title(testTitle) + .build() + )); + + // When + FolderRequest.FolderDto request = FolderRequest.FolderDto.builder() + .title(testTitle) + .build(); + + FolderResponse.FolderDtoList response = folderService.createFolder(testId, request); + + // Then + verify(userRepository).findById(testId); + verify(folderRepository).save(any(Folder.class)); + verify(folderRepository).findFolderDtoList(user); + + assertThat(response.getFolderDtoList()).isNotNull(); + assertThat(response.getFolderDtoList().get(0).getTitle()).isEqualTo(testTitle); + assertThat(response.getFolderDtoList().get(0).getFolderId()).isEqualTo(testId); + } + + @Test + @DisplayName("폴더명 수정 기능") + void updateFolder() { + // Given + String updatedTitle = "Updated Title"; + + User user = createMockUser(testId, "Test User"); + Folder folder = createMockFolder(testId, testTitle, user); + user.getFolders().add(folder); + + when(userRepository.findById(testId)).thenReturn(Optional.of(user)); + when(folderRepository.findById(testId)).thenReturn(Optional.ofNullable(folder)); + when(folderRepository.existsByTitle(updatedTitle)).thenReturn(false); + when(folderRepository.findFolderDtoList(user)).thenReturn(List.of( + FolderResponse.FolderDto.builder() + .folderId(testId) + .title(updatedTitle) + .build() + )); + + // When + FolderRequest.FolderUpdateDto request = FolderRequest.FolderUpdateDto.builder() + .folderId(testId) + .title(updatedTitle) + .build(); + + FolderResponse.FolderDtoList response = folderService.updateFolder(testId, request); + + // Then + verify(folderRepository).findById(testId); + verify(folderRepository).existsByTitle(updatedTitle); + verify(folderRepository).findFolderDtoList(user); + + assertThat(response.getFolderDtoList()).isNotNull(); + assertThat(response.getFolderDtoList().get(0).getTitle()).isEqualTo(updatedTitle); + assertThat(response.getFolderDtoList().get(0).getFolderId()).isEqualTo(testId); + assertThat(folder.getTitle()).isEqualTo(updatedTitle); + } + + @Test + @DisplayName("중복된 폴더 생성 시 오류 반환 테스트") + void createDuplicateFolder() { + // Given + User user = createMockUser(testId, "Test User"); + Folder folder1 = createMockFolder(testId, testTitle, user); + user.getFolders().add(folder1); + + Folder folder2 = createMockFolder(testId + 1, testTitle, user); + + when(userRepository.findById(testId)).thenReturn(Optional.of(user)); + when(folderRepository.existsByTitle(testTitle)).thenReturn(true); + + // When & Then + FolderRequest.FolderDto request = FolderRequest.FolderDto.builder() + .title(testTitle) + .build(); + + assertThat(user.getFolders()).isEqualTo(List.of(folder1)); + assertThrows(FolderException.class, () -> folderService.createFolder(testId, request)); + + verify(userRepository).findById(testId); + verify(folderRepository).existsByTitle(testTitle); + verify(folderRepository, never()).save(folder2); + } + + private User createMockUser(Long userId, String nickName) { + return User.builder() + .userId(userId) + .providerId("Test Provider") + .nickName(nickName) + .status(Status.GRADUATE_STUDENT) + .folders(new ArrayList<>()) + .build(); + } + + private Folder createMockFolder(Long folderId, String title, User user) { + return Folder.builder() + .folderId(folderId) + .title(title) + .user(user) + .build(); + } + +} diff --git a/src/test/java/corecord/dev/record/memo/repository/MemoRecordRepositoryTest.java b/src/test/java/corecord/dev/record/memo/repository/MemoRecordRepositoryTest.java new file mode 100644 index 0000000..1636504 --- /dev/null +++ b/src/test/java/corecord/dev/record/memo/repository/MemoRecordRepositoryTest.java @@ -0,0 +1,184 @@ +package corecord.dev.record.memo.repository; + +import corecord.dev.domain.ability.domain.entity.Ability; +import corecord.dev.domain.ability.domain.entity.Keyword; +import corecord.dev.domain.ability.domain.repository.AbilityRepository; +import corecord.dev.domain.analysis.domain.entity.Analysis; +import corecord.dev.domain.analysis.domain.repository.AnalysisRepository; +import corecord.dev.domain.folder.domain.entity.Folder; +import corecord.dev.domain.folder.domain.repository.FolderRepository; +import corecord.dev.domain.record.domain.entity.RecordType; +import corecord.dev.domain.record.domain.entity.Record; +import corecord.dev.domain.record.domain.repository.RecordRepository; +import corecord.dev.domain.user.domain.entity.Status; +import corecord.dev.domain.user.domain.entity.User; +import corecord.dev.domain.user.domain.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DataJpaTest +@Transactional +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class MemoRecordRepositoryTest { + + @Autowired + private RecordRepository recordRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private FolderRepository folderRepository; + + @Autowired + private AnalysisRepository analysisRepository; + + @Autowired + private AbilityRepository abilityRepository; + + private final Long lastRecordId = 0L; + private final Pageable pageable = PageRequest.of(0, 5); + private final String testContent = "Test Content"; + + + @Test + @DisplayName("폴더별 경험 기록 리스트 조회 테스트") + void findRecordByFolder() { + // Given + User user = createUser("Test User"); + Folder folder = createFolder("Test Folder", user); + + Record record1 = createRecord("Test Record1", user, folder); + Record record2 = createRecord("Test Record2", user, folder); + + // When + List result = recordRepository.findRecordsByFolder(folder, user, lastRecordId, pageable); + + // Then + assertThat(result.size()).isEqualTo(2); + assertThat(result.get(0).getRecordId()).isEqualTo(record1.getRecordId()); + assertThat(result.get(0).getFolder().getFolderId()).isEqualTo(folder.getFolderId()); + assertThat(result.get(1).getRecordId()).isEqualTo(record2.getRecordId()); + assertThat(result.get(1).getFolder().getFolderId()).isEqualTo(folder.getFolderId()); + } + + @Test + @DisplayName("경험 기록이 존재하지 않는 폴더에 대한 리스트 조회 테스트") + void findRecordByFolderWhenNoRecordsExist() { + // Given + User user = createUser("Test User"); + Folder folder = createFolder("Test Folder", user); + + // When + List result = recordRepository.findRecordsByFolder(folder, user, lastRecordId, pageable); + + // Then + assertEquals(result.size(), 0); + } + + @Test + @DisplayName("메모 경험 기록 조회 테스트") + void findMemoRecordDetail() { + // Given + User user = createUser("Test User"); + Folder folder = createFolder("Test folder", user); + Record record = createRecord("Test Record", user, folder); + + // When + Optional result = recordRepository.findRecordById(record.getRecordId()); + + // Then + assertTrue(result.isPresent()); + assertThat(result.get().getTitle()).isEqualTo("Test Record"); + assertThat(result.get().getRecordId()).isEqualTo(record.getRecordId()); + } + + @Test + @DisplayName("키워드별 경험 기록 조회 테스트") + void findMemoRecordListByKeywordTest() { + // Given + User user = createUser("Test User"); + Folder folder = createFolder("Test folder", user); + + Record record1 = createRecord("Test Record1", user, folder); + Record record2 = createRecord("Test Record2", user, folder); + + // When + List result = recordRepository.findRecordsByKeyword(Keyword.COLLABORATION, user, lastRecordId, pageable); + + // Then + assertEquals(result.size(), 2); + assertEquals(result.get(0).getTitle(), record1.getTitle()); + assertEquals(result.get(1).getTitle(), record2.getTitle()); + } + + private User createUser(String nickName) { + User user = User.builder() + .providerId("Test Provider") + .nickName(nickName) + .status(Status.GRADUATE_STUDENT) + .folders(new ArrayList<>()) + .build(); + userRepository.save(user); + return user; + } + + private Record createRecord(String title, User user, Folder folder) { + Record record = Record.builder() + .title(title) + .content(testContent) + .user(user) + .type(RecordType.MEMO) + .folder(folder) + .build(); + recordRepository.save(record); + createAnalysis(record, user); + return record; + } + + private Folder createFolder(String title, User user) { + Folder folder = Folder.builder() + .title(title) + .user(user) + .build(); + folderRepository.save(folder); + return folder; + } + + private Analysis createAnalysis(Record record, User user) { + Analysis analysis = Analysis.builder() + .content(testContent) + .comment(testContent) + .record(record) + .build(); + analysisRepository.save(analysis); + createAbility(user, analysis, Keyword.COLLABORATION); + return analysis; + } + + private Ability createAbility(User user, Analysis analysis, Keyword keyword) { + Ability ability = Ability.builder() + .keyword(keyword) + .content(testContent) + .user(user) + .analysis(analysis) + .build(); + abilityRepository.save(ability); + return ability; + } + +} diff --git a/src/test/java/corecord/dev/record/memo/service/MemoRecordServiceTest.java b/src/test/java/corecord/dev/record/memo/service/MemoRecordServiceTest.java new file mode 100644 index 0000000..9082aec --- /dev/null +++ b/src/test/java/corecord/dev/record/memo/service/MemoRecordServiceTest.java @@ -0,0 +1,263 @@ +package corecord.dev.record.memo.service; + +import corecord.dev.domain.analysis.domain.entity.Analysis; +import corecord.dev.domain.analysis.application.AnalysisService; +import corecord.dev.domain.folder.domain.entity.Folder; +import corecord.dev.domain.folder.domain.repository.FolderRepository; +import corecord.dev.domain.record.domain.entity.RecordType; +import corecord.dev.domain.record.domain.dto.request.RecordRequest; +import corecord.dev.domain.record.domain.dto.response.RecordResponse; +import corecord.dev.domain.record.domain.entity.Record; +import corecord.dev.domain.record.status.RecordErrorStatus; +import corecord.dev.domain.record.exception.RecordException; +import corecord.dev.domain.record.domain.repository.RecordRepository; +import corecord.dev.domain.record.application.RecordService; +import corecord.dev.domain.user.domain.entity.Status; +import corecord.dev.domain.user.domain.entity.User; +import corecord.dev.domain.user.domain.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + + +@ExtendWith(MockitoExtension.class) +public class MemoRecordServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private FolderRepository folderRepository; + + @Mock + private RecordRepository recordRepository; + + @Mock + private AnalysisService analysisService; + + @InjectMocks + private RecordService recordService; + + private User user; + private Folder folder; + + private String testTitle = "Test Record"; + private String testContent = "Test".repeat(10); + + @BeforeEach + void setUp() { + user = createMockUser(); + folder = createMockFolder(user); + } + @Test + @DisplayName("메모 경험 기록 생성 테스트") + void createMemoRecordTest() { + // Given + Record record = createMockRecord(user, folder); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(folderRepository.findById(1L)).thenReturn(Optional.of(folder)); + when(analysisService.createAnalysis(any(Record.class), any(User.class))) + .thenReturn(createMockAnalysis(record)); + when(recordRepository.save(any(Record.class))).thenAnswer(invocation -> { + Record savedRecord = invocation.getArgument(0); + savedRecord.setCreatedAt(LocalDateTime.now()); + return savedRecord; + }); + + // When + RecordRequest.RecordDto request = RecordRequest.RecordDto.builder() + .title(testTitle) + .content(testContent) + .folderId(1L) + .recordType(RecordType.MEMO) + .build(); + + RecordResponse.MemoRecordDto response = recordService.createMemoRecord(1L, request); + + // Then + verify(userRepository).findById(1L); + verify(folderRepository).findById(1L); + verify(recordRepository).save(any(Record.class)); + + assertEquals(response.getFolder(), folder.getTitle()); + assertEquals(response.getTitle(), testTitle); + assertEquals(response.getContent(), testContent); + } + + @Test + @DisplayName("경험 기록 제목이 긴 경우 예외 발생") + void createMemoRecordWithLongContent() { + // Given + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(folderRepository.findById(1L)).thenReturn(Optional.of(folder)); + + // When & Then + RecordRequest.RecordDto request = RecordRequest.RecordDto.builder() + .title("a".repeat(51)) + .content(testContent) + .folderId(1L) + .recordType(RecordType.MEMO) + .build(); + + RecordException exception = assertThrows(RecordException.class, + () -> recordService.createMemoRecord(1L, request)); + assertEquals(exception.getRecordErrorStatus(), RecordErrorStatus.OVERFLOW_MEMO_RECORD_TITLE); + } + + @Test + @DisplayName("경험 기록 내용 글자수가 충분하지 않은 경우 예외 발생") + void createMemoRecordWithNotEnoughContent() { + // Given + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(folderRepository.findById(1L)).thenReturn(Optional.of(folder)); + + // When & Then + RecordRequest.RecordDto request = RecordRequest.RecordDto.builder() + .title(testTitle) + .content("Test") + .folderId(1L) + .recordType(RecordType.MEMO) + .build(); + + RecordException exception = assertThrows(RecordException.class, + () -> recordService.createMemoRecord(1L, request)); + assertEquals(exception.getRecordErrorStatus(), RecordErrorStatus.NOT_ENOUGH_MEMO_RECORD_CONTENT); + } + + @Test + @DisplayName("임시 메모 경험 기록 저장 테스트") + void createTmpMemoRecordTest() { + // Given + Record tmpRecord = createMockRecord(user, null); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(recordRepository.save(any(Record.class))).thenReturn(tmpRecord); + + // When + RecordRequest.TmpMemoRecordDto request = RecordRequest.TmpMemoRecordDto.builder() + .title(testTitle) + .content(testContent) + .build(); + + recordService.createTmpMemoRecord(1L, request); + + // Then + verify(userRepository, times(1)).findById(1L); + verify(recordRepository, times(1)).save(any(Record.class)); + assertEquals(user.getTmpMemo(), tmpRecord.getRecordId()); + } + + @Test + @DisplayName("중복 임시 메모 경험 기록 저장 시 예외 발생 테스트") + void createTmpMemoRecordDuplicateTest() { + // Given + user.updateTmpMemo(1L); // 이미 임시 메모 경험 기록을 저장 + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + // When & Then + RecordRequest.TmpMemoRecordDto request = RecordRequest.TmpMemoRecordDto.builder() + .title(testTitle) + .content(testContent) + .build(); + + RecordException exception = assertThrows(RecordException.class, + () -> recordService.createTmpMemoRecord(1L, request)); + assertEquals(exception.getRecordErrorStatus(), RecordErrorStatus.ALREADY_TMP_MEMO); + + verify(userRepository, times(1)).findById(1L); + verify(recordRepository, times(0)).save(any(Record.class)); + } + + @Test + @DisplayName("임시 메모 경험 기록이 있는 경우 조회 테스트") + void getTmpMemoRecordTest() { + // Given + Record tmpRecord = createMockRecord(user, null); + user.updateTmpMemo(1L); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(recordRepository.findById(1L)).thenReturn(Optional.of(tmpRecord)); + + // When + RecordResponse.TmpMemoRecordDto response = recordService.getTmpMemoRecord(1L); + + // Then + verify(userRepository, times(1)).findById(1L); + verify(recordRepository, times(1)).findById(1L); + verify(recordRepository, times(1)).delete(tmpRecord); + + assertNull(user.getTmpMemo()); + assertTrue(response.getIsExist()); + assertEquals(response.getTitle(), testTitle); + assertEquals(response.getContent(), testContent); + } + + @Test + @DisplayName("임시 메모 경험 기록이 없는 경우 조회 테스트") + void getTmpMemoRecordWithoutRecordTest() { + // Given + user.updateTmpMemo(null); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + // When + RecordResponse.TmpMemoRecordDto response = recordService.getTmpMemoRecord(1L); + + // Then + verify(userRepository, times(1)).findById(1L); + + assertFalse(response.getIsExist()); + assertNull(response.getTitle()); + assertNull(response.getContent()); + } + + private User createMockUser() { + return User.builder() + .userId(1L) + .providerId("Test Provider") + .nickName("Test User") + .status(Status.GRADUATE_STUDENT) + .folders(new ArrayList<>()) + .build(); + } + + private Folder createMockFolder(User user) { + return Folder.builder() + .folderId(1L) + .title("Test Folder") + .user(user) + .build(); + } + + private Record createMockRecord(User user, Folder folder) { + return Record.builder() + .recordId(1L) + .title(testTitle) + .content(testContent) + .user(user) + .type(RecordType.MEMO) + .folder(folder) + .build(); + } + + private Analysis createMockAnalysis(Record record) { + return Analysis.builder() + .analysisId(1L) + .content(testContent) + .comment("Test Comment") + .record(record) + .build(); + } + +} diff --git a/src/test/java/corecord/dev/user/repository/UserRepositoryTest.java b/src/test/java/corecord/dev/user/repository/UserRepositoryTest.java new file mode 100644 index 0000000..42f80b5 --- /dev/null +++ b/src/test/java/corecord/dev/user/repository/UserRepositoryTest.java @@ -0,0 +1,59 @@ +package corecord.dev.user.repository; + +import corecord.dev.domain.user.domain.entity.Status; +import corecord.dev.domain.user.domain.entity.User; +import corecord.dev.domain.user.domain.repository.UserRepository; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +@DataJpaTest +@Transactional +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class UserRepositoryTest { + @Autowired + UserRepository userRepository; + + @Autowired + EntityManager entityManager; + + @Test + @DisplayName("UserId로 회원 삭제") + void deleteUserByUserId() { + // Given + User user = createTestUser(); + userRepository.save(user); + + // When + userRepository.deleteUserByUserId(user.getUserId()); + entityManager.flush(); + entityManager.clear(); + + // Then + Optional deletedUser = userRepository.findById(user.getUserId()); + assertThat(deletedUser).isEmpty(); + } + + + private User createTestUser() { + return User.builder() + .userId(1L) + .providerId("providerId") + .nickName("testUser") + .status(Status.UNIVERSITY_STUDENT) + .abilities(new ArrayList<>()) + .chatRooms(new ArrayList<>()) + .folders(new ArrayList<>()) + .records(new ArrayList<>()) + .build(); + } +} diff --git a/src/test/java/corecord/dev/user/service/UserServiceTest.java b/src/test/java/corecord/dev/user/service/UserServiceTest.java new file mode 100644 index 0000000..f0b9a6a --- /dev/null +++ b/src/test/java/corecord/dev/user/service/UserServiceTest.java @@ -0,0 +1,184 @@ +package corecord.dev.user.service; + +import corecord.dev.common.util.CookieUtil; +import corecord.dev.domain.auth.domain.repository.RefreshTokenRepository; +import corecord.dev.domain.auth.jwt.JwtUtil; +import corecord.dev.domain.record.domain.repository.RecordRepository; +import corecord.dev.domain.user.domain.dto.request.UserRequest; +import corecord.dev.domain.user.domain.dto.response.UserResponse; +import corecord.dev.domain.user.domain.entity.Status; +import corecord.dev.domain.user.domain.entity.User; +import corecord.dev.domain.user.status.UserErrorStatus; +import corecord.dev.domain.user.exception.UserException; +import corecord.dev.domain.user.domain.repository.UserRepository; +import corecord.dev.domain.user.application.UserService; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseCookie; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class UserServiceTest { + + @Mock + private JwtUtil jwtUtil; + + @Mock + private CookieUtil cookieUtil; + + @Mock + private UserRepository userRepository; + + @Mock + private RefreshTokenRepository refreshTokenRepository; + + @Mock + private RecordRepository recordRepository; + + @Mock + private HttpServletResponse response; + + @InjectMocks + private UserService userService; + + private static final long ACCESS_TOKEN_EXPIRATION = 86400000L; + private static final long REFRESH_TOKEN_EXPIRATION = 2592000000L; + private static final String REGISTER_TOKEN = "validRegisterToken"; + private static final String PROVIDER_ID = "1234567890"; + private static final String REFRESH_TOKEN = "generatedRefreshToken"; + private static final String ACCESS_TOKEN = "generatedAccessToken"; + private User newUser; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(userService, "accessTokenExpirationTime", ACCESS_TOKEN_EXPIRATION); + ReflectionTestUtils.setField(userService, "refreshTokenExpirationTime", REFRESH_TOKEN_EXPIRATION); + newUser = createTestUser(PROVIDER_ID); + } + + @Test + @DisplayName("유저 회원가입 테스트") + void registerUser() { + // Given + UserRequest.UserRegisterDto userRegisterDto = new UserRequest.UserRegisterDto(); + userRegisterDto.setNickName("testUser"); + userRegisterDto.setStatus("대학생"); + + when(jwtUtil.isRegisterTokenValid(REGISTER_TOKEN)).thenReturn(true); + when(jwtUtil.getProviderIdFromToken(REGISTER_TOKEN)).thenReturn(PROVIDER_ID); + when(jwtUtil.generateRefreshToken(anyLong())).thenReturn(REFRESH_TOKEN); + when(jwtUtil.generateAccessToken(anyLong())).thenReturn(ACCESS_TOKEN); + when(userRepository.existsByProviderId(PROVIDER_ID)).thenReturn(false); + when(userRepository.save(any(User.class))).thenReturn(newUser); + when(cookieUtil.createTokenCookie(eq("refreshToken"), eq(REFRESH_TOKEN), eq(REFRESH_TOKEN_EXPIRATION))) + .thenReturn(ResponseCookie.from("refreshToken", REFRESH_TOKEN).build()); + when(cookieUtil.createTokenCookie(eq("accessToken"), eq(ACCESS_TOKEN), eq(ACCESS_TOKEN_EXPIRATION))) + .thenReturn(ResponseCookie.from("accessToken", ACCESS_TOKEN).build()); + + // When + UserResponse.UserDto userDto = userService.registerUser(response, REGISTER_TOKEN, userRegisterDto); + + // Then + assertThat(userDto.getNickname()).isEqualTo(newUser.getNickName()); + assertThat(userDto.getStatus()).isEqualTo(newUser.getStatus().getValue()); + + ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(String.class); + verify(response, times(2)).addHeader(eq("Set-Cookie"), cookieCaptor.capture()); + + assertThat(cookieCaptor.getAllValues()).containsExactlyInAnyOrder( + ResponseCookie.from("refreshToken", REFRESH_TOKEN).build().toString(), + ResponseCookie.from("accessToken", ACCESS_TOKEN).build().toString() + ); + + } + + @Test + @DisplayName("회원 정보 조회 테스트") + void getUserInfo() { + // Given + when(userRepository.findById(newUser.getUserId())).thenReturn(Optional.of(newUser)); + + // When + UserResponse.UserInfoDto userInfoDto = userService.getUserInfo(newUser.getUserId()); + + // Then + assertThat(userInfoDto.getNickname()).isEqualTo(newUser.getNickName()); + assertThat(userInfoDto.getStatus()).isEqualTo(newUser.getStatus().getValue()); + } + + @Test + @DisplayName("회원 정보 수정 테스트") + void updateUser() { + // Given + UserRequest.UserUpdateDto updateDto = new UserRequest.UserUpdateDto(); + updateDto.setNickName("editName"); + updateDto.setStatus("인턴"); + + when(userRepository.findById(newUser.getUserId())).thenReturn(Optional.of(newUser)); + + // When + userService.updateUser(newUser.getUserId(), updateDto); + + // Then + assertThat(newUser.getNickName()).isEqualTo("editName"); + assertThat(newUser.getStatus()).isEqualTo(Status.INTERN); + verify(userRepository).findById(newUser.getUserId()); + } + + @Test + @DisplayName("닉네임 유효성 검증 - 닉네임이 길이 초과일 때 예외 발생") + void validateUserInfo_NickNameExceedsLength_ThrowsUserException() { + // Given + String invalidNickName = "VeryLongNickname"; + UserRequest.UserRegisterDto userRegisterDto = new UserRequest.UserRegisterDto(); + userRegisterDto.setNickName(invalidNickName); + userRegisterDto.setStatus("대학생"); + + when(jwtUtil.isRegisterTokenValid(REGISTER_TOKEN)).thenReturn(true); + + // When & Then + UserException exception = Assertions.assertThrows(UserException.class, + () -> userService.registerUser(response, REGISTER_TOKEN, userRegisterDto)); + assertThat(exception.getUserErrorStatus()).isEqualTo(UserErrorStatus.INVALID_USER_NICKNAME); + } + + @Test + @DisplayName("닉네임 유효성 검증 - 닉네임에 허용되지 않는 문자가 포함된 경우 예외 발생") + void validateUserInfo_NickNameHasInvalidCharacters_ThrowsUserException() { + // Given + String invalidNickName = "Invalid@Nickname!"; + UserRequest.UserRegisterDto userRegisterDto = new UserRequest.UserRegisterDto(); + userRegisterDto.setNickName(invalidNickName); + userRegisterDto.setStatus("대학생"); + + when(jwtUtil.isRegisterTokenValid(REGISTER_TOKEN)).thenReturn(true); + + // When & Then + UserException exception = Assertions.assertThrows(UserException.class, + () -> userService.registerUser(response, REGISTER_TOKEN, userRegisterDto)); + assertThat(exception.getUserErrorStatus()).isEqualTo(UserErrorStatus.INVALID_USER_NICKNAME); + } + + private User createTestUser(String providerId) { + return User.builder() + .userId(1L) + .providerId(providerId) + .nickName("testUser") + .status(Status.UNIVERSITY_STUDENT) + .build(); + } +}