diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..54daa1fd7 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,94 @@ +name: deploy + +on: + pull_request: + branches: ["main", "cherry-pick-sprint8"] + +jobs: + build-and-push: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: 소스 체크 아웃 + uses: actions/checkout@v3 + + - name: gradlew 실행 권한 부여 + run: chmod +x gradlew + + - name: JDK 17 설정 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Gradle 캐시 (선택) + uses: gradle/actions/setup-gradle@v4 + + - name: 애플리케이션 빌드 + run: ./gradlew clean bootJar + + - name: AWS 자격 증명 설정 + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: ECR 로그인 + run: | + aws ecr get-login-password --region ${{ secrets.AWS_REGION }} \ + | docker login --username AWS --password-stdin \ + ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com + + - name: 태그 구성 - 커밋 해시 7자리 + latest + id: prep + run: | + echo "IMAGE_TAG=${GITHUB_SHA::7}" >> $GITHUB_ENV + echo "REPO_URI=${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/discodeit/discodeit" >> $GITHUB_ENV + + - name: 도커 빌드 + run: | + docker build -t "$REPO_URI:$IMAGE_TAG" -t "$REPO_URI:latest" . + + - name: ECR 푸시 + run: | + docker push "$REPO_URI:$IMAGE_TAG" + docker push "$REPO_URI:latest" + + - name: 작업 정의 템플릿 렌더링 + run: | + sudo apt-get update -y && sudo apt-get install -y gettext-base + export AWS_ACCOUNT_ID=${{ secrets.AWS_ACCOUNT_ID }} + export AWS_REGION=${{ secrets.AWS_REGION }} + export IMAGE_TAG=${{ env.IMAGE_TAG }} + envsubst < ecs-task-def.json > task-def.rendered.json + echo "=== Rendered task def ===" + cat task-def.rendered.json + + - name: 작업 정의 업데이트 + run: | + aws ecs register-task-definition \ + --cli-input-json file://task-def.rendered.json + + - name: ECS 서비스 업데이트 + run: | + aws ecs update-service \ + --cluster ${{ secrets.ECS_CLUSTER }} \ + --service ${{ secrets.ECS_SERVICE }} \ + --force-new-deployment + + - name: ECS 배포 완료 대기 + run: | + aws ecs wait services-stable \ + --cluster ${{ secrets.ECS_CLUSTER }} \ + --services ${{ secrets.ECS_SERVICE }} + + - name: ECS 배포 상태 확인 + run: | + aws ecs describe-services \ + --cluster ${{ secrets.ECS_CLUSTER }} \ + --services ${{ secrets.ECS_SERVICE }} \ + --query "services[0].deployments" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..712c34a9c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +on: + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'corretto' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + # Gradle 캐싱 설정 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-read-only: false + + - name: Build with Gradle + run: ./gradlew build test + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + diff --git a/.gitignore b/.gitignore index d41e7a3aa..5580ad467 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,14 @@ bin/ out/ !**/src/main/**/out/ !**/src/test/**/out/ +.env +.discoedit/** +src/main/resources/static/** +src/main/generated/** +logs/** +{discodeit.storage.local.root-path} +/build/generated/** + ### NetBeans ### /nbproject/private/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..03bdad924 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +#ARG는 값을 외부에서 덮어쓸 수 있으므로 사용x +# =========== (1) Build ============= +FROM gradle:7.6.0-jdk17 AS build + +# 루트 권한으로 변경 (권한 설정/ 폴더 생성작업을 위해) +USER root +# 애플리케이션 작업 디렉토리 설정 +WORKDIR /app +# Gradle 캐시 디토리와 앱 디렉토리 소유자를 gradle 유저로 변경 +RUN mkdir -p /home/gradle/.gradle && chown -R gradle:gradle /home/gradle /app +# gradle 유저로 변경(보안 및 권한 문제 방지) +User gradle + +# Gradle Wrapper 스크립트 복사 (빌드 실행에 필요) +COPY --chown=gradle:gradle gradlew ./ +# gradle 폴더 복사 (wrapper 설정 및 실행 환경) +COPY --chown=gradle:gradle gradle ./gradle +# Gradle 설정 파일 복사 (빌드 스크립트) +COPY --chown=gradle:gradle settings.gradle build.gradle ./ +# gradlew 실행 권한 부여 +RUN chmod +x ./gradlew +# 의존성만 먼저 다운로드하여 캐시 활용 (코드 변경 없이 재사용 가능) +RUN ./gradlew --no-daemon --refresh-dependencies dependencies || true +# 실제 소스코드 복사 (이 시점 이후 변경 시 빌드 다시 수행됨) +COPY --chown=gradle:gradle src ./src +# 애플리케이션 빌드 +RUN ./gradlew clean build + + +# ============ (2) Runtime ============ +# 런타임 스테이지: 빌드 결과 실행에 필요한 최소한의 경량 이미지 사용 +FROM amazoncorretto:17 +# 앱 실행 디렉토리 지정 +WORKDIR /app + +# 빌드 스테이지에서 생성한 JAR 파일만 복사 +COPY --from=build /app/build/libs/*.jar app.jar +# 애플리케이션이 사용하는 포트 노출 +EXPOSE 80 +# 프로젝트 정보 환경변수 설정 +ENV PROJECT_VERSION=1.2-M8 +ENV PROJECT_NAME=discodeit +#JVM_OPTS: JVM 옵션 +ENV JVM_OPTS="" +# Spring Boot 프로필을 운영(prod)으로 설정 +ENV SPRING_PROFILES_ACTIVE=prod +# 컨테이너 시작 시 JAR 실행 +#CMD["sh", "-c", "exec", "java", "${JVM_OPTS}", "-jar", "${PROJECT_NAME}-${PROJECT_VERSION}.jar"] +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/README.md b/README.md new file mode 100644 index 000000000..a8702ea45 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ + +[![codecov](https://codecov.io/github/kdfasdf/4-sprint-mission/branch/sprint8/graph/badge.svg?token=Z4ZWRVXB5J)](https://codecov.io/github/kdfasdf/4-sprint-mission) +### REST API 설계원칙 준수 +- 리소스 중심의 URI 설계 + - GET /api/users/{userId} +- 명확한 상태코드 응답 제공 + - request : DELETE /api/users/{userId} + - response : ResponseEntity.noContent().build() +- 필터링, 정렬, 페이징에 쿼리 파라미터 사용 + - request : /channels?userId=d3ee2929-212b-4077-af84-694a0e69b8e1 + +### 예외 처리 +- Global + - GlobalHandlerExceptionResolver를 통한 전역 예외 처리 + - ErrorResponse(String message, String code, ins status, List\ errors, List\ violationErrors) 형식으로 응답형식 통일 + - 커스텀 예외를 발생시키는 경우 상황에 맞는 오류 메시지를 전달하여 서버 내부 오류 메시지 노출 최소화 +- Controller + - bean validation을 통한 요청 검증 +- Service + - 도메인과 비즈니스 로직 상황에 맞는 커스텀 예외 발생 + +### OOP +- 무분별한 @Setter 방지를 통한 캡슐화 원칙 준수 + - 수정 가능한 필드에 대해서 비즈니스적인 의미를 가지는 setter 메서드 구현 + +### Test Code +- 슬라이스 테스트 + - mockito 라이브러리를 사용하여 controller, repository계층 슬라이스 테스트, 서비스 계층 단위테스트를 통한 계층 별 동작 검증 +- 통합 테스트 + - controller 계층 통합테스트를 진행하여 프로덕션 코드 안정성 보장 + +### Logging +- MDC Interceptor + - 요청별 고유 ID(requestId) 요청 생성 및 HTTP 메서드, 요청 경로 정보 자동 수집 + - afterCompletion에서 MDC 정리를 통한 메모리 누수 방지 +- AOP + - MethodLoggingAspect를 통한 비즈니스 로직 요청 응답 추적 + - LogParameterFormatter를 통한 로그 출력 형식 표준화 + - 리플렉션을 통한 요청 필드 상세 분석 + - 민감정보 마스킹 + +### API 문서화 +- swagger를 통한 API 문서화 + - 인터페이스로 추상화하여 controller 계층에 non-invasive하도록 문서화 + +### Docker +- 멀티스테이지 빌드: Build와 Runtime 스테이지 분리로 최종 이미지 크기 최적화 + - Build 스테이지: Gradle 기반 애플리케이션 컴파일 및 의존성 관리 + - Runtime 스테이지: Amazon Corretto JDK17 경량 런타임 환경 +- 레이어 캐싱 최적화: 의존성과 소스코드 복사 단계 분리로 빌드 성능 향상 + - Gradle 설정 파일 우선 복사 후 의존성 다운로드 + - 소스코드 변경 시에도 의존성 레이어 캐시 재사용 + +### CI/CD +- GitHub Actions를 통한 자동화된 빌드 및 배포 파이프라인 구성 + - Pull Request 시 자동 테스트 실행 및 코드 커버리지 측정 + - CI + - JDK 17 (Amazon Corretto) 기반 빌드 환경 구성 + - Gradle 캐싱을 통한 빌드 성능 최적화 + - CD + - Docker 기반 컨테이너화된 애플리케이션 배포 + - AWS ECR를 통한 컨테이너 이미지 저장 및 ECS를 정의를 통한 배포 + diff --git a/admin/.gitattributes b/admin/.gitattributes new file mode 100644 index 000000000..8af972cde --- /dev/null +++ b/admin/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/admin/.gitignore b/admin/.gitignore new file mode 100644 index 000000000..c2065bc26 --- /dev/null +++ b/admin/.gitignore @@ -0,0 +1,37 @@ +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/ diff --git a/admin/build.gradle b/admin/build.gradle new file mode 100644 index 000000000..f1e56495c --- /dev/null +++ b/admin/build.gradle @@ -0,0 +1,39 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.5.5' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.codeit' +version = '0.0.1-SNAPSHOT' +description = 'admin' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { + mavenCentral() +} + +ext { + set('springBootAdminVersion', "3.5.0") +} + +dependencies { + implementation 'de.codecentric:spring-boot-admin-starter-server' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +dependencyManagement { + imports { + mavenBom "de.codecentric:spring-boot-admin-dependencies:${springBootAdminVersion}" + } +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/admin/gradle/wrapper/gradle-wrapper.jar b/admin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..1b33c55ba Binary files /dev/null and b/admin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/admin/gradle/wrapper/gradle-wrapper.properties b/admin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..d4081da47 --- /dev/null +++ b/admin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/admin/gradlew b/admin/gradlew new file mode 100644 index 000000000..23d15a936 --- /dev/null +++ b/admin/gradlew @@ -0,0 +1,251 @@ +#!/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\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# 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, 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" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/admin/gradlew.bat b/admin/gradlew.bat new file mode 100644 index 000000000..db3a6ac20 --- /dev/null +++ b/admin/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= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/admin/settings.gradle b/admin/settings.gradle new file mode 100644 index 000000000..e83a19cde --- /dev/null +++ b/admin/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'admin' diff --git a/admin/src/main/java/com/codeit/admin/AdminApplication.java b/admin/src/main/java/com/codeit/admin/AdminApplication.java new file mode 100644 index 000000000..678b2a0f0 --- /dev/null +++ b/admin/src/main/java/com/codeit/admin/AdminApplication.java @@ -0,0 +1,15 @@ +package com.codeit.admin; + +import de.codecentric.boot.admin.server.config.EnableAdminServer; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +@EnableAdminServer +public class AdminApplication { + + public static void main(String[] args) { + SpringApplication.run(AdminApplication.class, args); + } + +} diff --git a/admin/src/main/resources/application.yaml b/admin/src/main/resources/application.yaml new file mode 100644 index 000000000..bad80608c --- /dev/null +++ b/admin/src/main/resources/application.yaml @@ -0,0 +1,6 @@ +# application.yaml +spring: + application: + name: admin +server: + port: 9090 diff --git a/admin/src/test/java/com/codeit/admin/AdminApplicationTests.java b/admin/src/test/java/com/codeit/admin/AdminApplicationTests.java new file mode 100644 index 000000000..300537bd4 --- /dev/null +++ b/admin/src/test/java/com/codeit/admin/AdminApplicationTests.java @@ -0,0 +1,13 @@ +package com.codeit.admin; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class AdminApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/build.gradle b/build.gradle index a1585e1e4..d996adf23 100644 --- a/build.gradle +++ b/build.gradle @@ -1,44 +1,144 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.5.0' - id 'io.spring.dependency-management' version '1.1.7' + id 'java' + id 'org.springframework.boot' version '3.5.0' + id 'io.spring.dependency-management' version '1.1.7' + id 'jacoco' } group = 'com.sprint.mission' -version = '0.0.1-SNAPSHOT' +version = '2.1-M10' java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } tasks.withType(JavaCompile) { - options.compilerArgs.add("-parameters") + options.compilerArgs.add("-parameters") } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() +} + +compileJava { + options.compilerArgs += [ + '-Amapstruct.defaultComponentModel=spring' + ] +} + +compileTestJava { + options.compilerArgs += [ + '-Amapstruct.defaultComponentModel=spring' + ] } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-aop' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + runtimeOnly 'org.postgresql:postgresql' + runtimeOnly 'com.h2database:h2' + + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' + + implementation 'org.mapstruct:mapstruct:1.5.5.Final' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final' + + implementation 'de.codecentric:spring-boot-admin-starter-client:3.3.3' + + implementation 'software.amazon.awssdk:s3:2.20.70' + implementation 'software.amazon.awssdk:auth:2.20.70' + + implementation 'com.nimbusds:nimbus-jose-jwt:10.3' + implementation 'org.springframework.retry:spring-retry' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' + implementation 'org.springframework.boot:spring-boot-starter-cache' + + implementation 'com.github.ben-manes.caffeine:caffeine' + + implementation 'org.springframework.kafka:spring-kafka' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacoco { + toolVersion = "0.8.10" + layout.buildDirectory.dir("reports/jacoco") +} + +test { + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + html.required = true + } + + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: [ + /* + "com/sprint/mission/discodeit/dto/**", + "com/sprint/mission/discodeit/storage/**", + "com/sprint/mission/discodeit/config/**", + "com/sprint/mission/discodeit/util/**", + "com/sprint/mission/discodeit/*Application", + "com/sprint/mission/discodeit/exception/**", + "com/sprint/mission/discodeit/mapper/**" + */ + ]) + })) + } +} + +jacocoTestCoverageVerification { + violationRules { + rule { + enabled = true + element = 'CLASS' + + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.00 + } + + limit { + counter = 'METHOD' + value = 'COVEREDRATIO' + minimum = 0.00 + } + } + } } diff --git a/data/user/5d925499-1196-4ddb-aed2-ba39431bc66e.ser b/data/user/5d925499-1196-4ddb-aed2-ba39431bc66e.ser deleted file mode 100644 index e5214dde7..000000000 Binary files a/data/user/5d925499-1196-4ddb-aed2-ba39431bc66e.ser and /dev/null differ diff --git a/data/userStatus/31520490-4382-4977-aee2-b6b3adb6842a.ser b/data/userStatus/31520490-4382-4977-aee2-b6b3adb6842a.ser deleted file mode 100644 index 8643ab599..000000000 Binary files a/data/userStatus/31520490-4382-4977-aee2-b6b3adb6842a.ser and /dev/null differ diff --git a/docker-compose-kafka.yaml b/docker-compose-kafka.yaml new file mode 100644 index 000000000..883b7ab9e --- /dev/null +++ b/docker-compose-kafka.yaml @@ -0,0 +1,25 @@ +# docker-compose-kafka.yaml +# https://developer.confluent.io/confluent-tutorials/kafka-on-docker/#the-docker-compose-file +services: + broker: + image: apache/kafka:latest + hostname: broker + container_name: broker + ports: + - 9092:9092 + environment: + KAFKA_BROKER_ID: 1 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT,CONTROLLER:PLAINTEXT + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://broker:29092,PLAINTEXT_HOST://localhost:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_NODE_ID: 1 + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@broker:29093 + KAFKA_LISTENERS: PLAINTEXT://broker:29092,CONTROLLER://broker:29093,PLAINTEXT_HOST://0.0.0.0:9092 + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_LOG_DIRS: /tmp/kraft-combined-logs + CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk diff --git a/docker-compose-redis.yaml b/docker-compose-redis.yaml new file mode 100644 index 000000000..954e3ea5a --- /dev/null +++ b/docker-compose-redis.yaml @@ -0,0 +1,14 @@ +# docker-compose-redis.yml +# https://developer.confluent.io/confluent-tutorials/kafka-on-docker/#the-docker-compose-file +services: + redis: + image: redis:7.2-alpine + container_name: redis + ports: + - "6379:6379" + volumes: + - redis-data:/data + command: redis-server --appendonly yes + +volumes: + redis-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..2e3e13eea --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +version: '3.8' + +services: #컨테이너 정의 + app: #애플리케이션 컨테이너 정의 + image: discodeit:1.0 + build: + context: . #현재 디렉토리의 도커파일을 통해 이미지 빌드 + dockerfile: Dockerfile + container_name: discodeit + ports: + - "8081:80" + environment: + - SPRING_PROFILES_ACTIVE=prod + - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/discodeit #도커 네트워크 브릿지 + - STORAGE_TYPE=${STORAGE_TYPE} + - SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME} + - SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD} + - AWS_S3_ACCESS_KEY=${AWS_S3_ACCESS_KEY} + - AWS_S3_SECRET_KEY=${AWS_S3_SECRET_KEY} + - AWS_S3_REGION=${AWS_S3_REGION} + - AWS_S3_BUCKET=${AWS_S3_BUCKET} + depends_on: #DB 컨테이너 실행 후 실행 + - db + volumes: + - binary-content-storage:/app/.discodeit/storage + networks: + - discodeit-network + db: + image: postgres:16-alpine + container_name: discodeit-db + environment: + - POSTGRES_DB=discodeit + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + #prod가 ddl-auto=validate 옵션을 사용하므로 + - ./src/main/resources/schema.sql:/docker-entrypoint-initdb.d/schema.sql + networks: + - discodeit-network + + +volumes: + binary-content-storage: + postgres-data: + +networks: + discodeit-network: + driver: bridge diff --git a/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java index bb7402f94..c1066cb5e 100644 --- a/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java +++ b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java @@ -1,10 +1,8 @@ package com.sprint.mission.discodeit; -import lombok.extern.slf4j.Slf4j; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -@Slf4j @SpringBootApplication public class DiscodeitApplication { public static void main(String[] args) { diff --git a/src/main/java/com/sprint/mission/discodeit/aop/MethodLoggingAspect.java b/src/main/java/com/sprint/mission/discodeit/aop/MethodLoggingAspect.java new file mode 100644 index 000000000..dca79c74a --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/aop/MethodLoggingAspect.java @@ -0,0 +1,48 @@ +package com.sprint.mission.discodeit.aop; + +import com.sprint.mission.discodeit.util.LogParameterFormatter; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.stereotype.Component; + +@Component +@Aspect +@Slf4j +public class MethodLoggingAspect { + + private final ThreadLocal startTimeHolder = new ThreadLocal<>(); + + @Before("execution(* com.sprint.mission.discodeit.service.*.*(..))") + public void methodCallLogging(JoinPoint joinPoint) { + String className = joinPoint.getSignature().getDeclaringType().getSimpleName(); + String methodName = joinPoint.getSignature().getName(); + Object[] args = joinPoint.getArgs(); + + String parameterStr = LogParameterFormatter.getFormattedParameters(args); + + startTimeHolder.set(System.currentTimeMillis()); + + log.info("Method call: (class={}, method={}, parameters=({})", className, methodName, parameterStr); + } + + @AfterReturning("execution(* com.sprint.mission.discodeit.service.*.*(..))") + public void methodReturnLogging(JoinPoint joinPoint) { + String className = joinPoint.getSignature().getDeclaringType().getSimpleName(); + String methodName = joinPoint.getSignature().getName(); + + //실행 시간 계산 + Long startTime = startTimeHolder.get(); + long executionTime = 0; + + if(startTime != null) { + executionTime = System.currentTimeMillis() - startTime; + startTimeHolder.remove(); + } + + log.info("Method return: (class : {}, method : {}, executionTime = {}ms)", className, methodName, executionTime); + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java b/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java new file mode 100644 index 000000000..f5a82ca01 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/AppConfig.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableRetry +@EnableScheduling +public class AppConfig { +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/AsyncConfig.java b/src/main/java/com/sprint/mission/discodeit/config/AsyncConfig.java new file mode 100644 index 000000000..85f022215 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/AsyncConfig.java @@ -0,0 +1,28 @@ +package com.sprint.mission.discodeit.config; + +import com.sprint.mission.discodeit.decorator.MdcTaskDecorator; +import java.util.concurrent.ThreadPoolExecutor; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +//@EnableAsync +@Configuration +public class AsyncConfig { + + private ThreadPoolTaskExecutor taskExecutor(int core, int max, int queue, int keepAlive, String prefix) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + + executor.setCorePoolSize(core); + executor.setMaxPoolSize(max); + executor.setQueueCapacity(queue); + executor.setKeepAliveSeconds(keepAlive); + executor.setThreadNamePrefix(prefix + "-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(20); + executor.setTaskDecorator(new MdcTaskDecorator()); + executor.initialize(); + + return executor; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/CacheConfig.java b/src/main/java/com/sprint/mission/discodeit/config/CacheConfig.java new file mode 100644 index 000000000..fed189058 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/CacheConfig.java @@ -0,0 +1,37 @@ +package com.sprint.mission.discodeit.config; + +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectMapper.DefaultTyping; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; +import java.time.Duration; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; + +@Configuration +@EnableCaching +public class CacheConfig { + @Bean + public RedisCacheConfiguration redisCacheConfiguration(ObjectMapper objectMapper) { + ObjectMapper redisObjectMapper = objectMapper.copy(); + redisObjectMapper.activateDefaultTyping( + LaissezFaireSubTypeValidator.instance, + DefaultTyping.EVERYTHING, + As.PROPERTY + ); + + return RedisCacheConfiguration.defaultCacheConfig() + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer( + new GenericJackson2JsonRedisSerializer(redisObjectMapper) + ) + ) + .prefixCacheNameWith("discodeit:") + .entryTtl(Duration.ofSeconds(600)) + .disableCachingNullValues(); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/JacksonConfig.java b/src/main/java/com/sprint/mission/discodeit/config/JacksonConfig.java new file mode 100644 index 000000000..78b65d76c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/JacksonConfig.java @@ -0,0 +1,19 @@ +package com.sprint.mission.discodeit.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); // 날짜, 시간타입 직,역렬화 모듈 + objectMapper.registerModule(new ParameterNamesModule()); //보다 다양한 조합으로 직,역직렬화 + return objectMapper; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/JpaAuditingConfig.java b/src/main/java/com/sprint/mission/discodeit/config/JpaAuditingConfig.java new file mode 100644 index 000000000..360ddbc6a --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@Configuration +public class JpaAuditingConfig { +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java b/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java new file mode 100644 index 000000000..6c4fd9a05 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java @@ -0,0 +1,35 @@ +package com.sprint.mission.discodeit.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.UUID; +import org.slf4j.MDC; +import org.springframework.web.servlet.HandlerInterceptor; + +public class MDCLoggingInterceptor implements HandlerInterceptor { + + public static final String REQUEST_ID = "requestId"; + public static final String REQUEST_HTTP_METHOD = "requestHttpMethod"; + public static final String REQUEST_PATH = "requestPath"; + + public static final String HEADER = "Discodeit-Request_ID"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String requestId = UUID.randomUUID().toString().replace("-",""); + + MDC.put(REQUEST_ID, requestId); + MDC.put(REQUEST_HTTP_METHOD, request.getMethod()); + MDC.put(REQUEST_PATH, request.getRequestURI()); + + response.setHeader(REQUEST_ID, requestId); + + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { + MDC.clear(); + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java new file mode 100644 index 000000000..00e8b14e6 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/SecurityConfig.java @@ -0,0 +1,139 @@ +package com.sprint.mission.discodeit.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.constant.AuthErrorCode; +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.exception.ErrorResponse; +import com.sprint.mission.discodeit.security.LoginFailureHandler; +import com.sprint.mission.discodeit.security.SpaCsrfTokenRequestHandler; +import com.sprint.mission.discodeit.security.jwt.JwtAuthenticationFilter; +import com.sprint.mission.discodeit.security.jwt.JwtLoginSuccessHandler; +import com.sprint.mission.discodeit.security.jwt.JwtLogoutHandler; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.session.HttpSessionEventPublisher; + +@Configuration +@RequiredArgsConstructor +@EnableMethodSecurity +@EnableWebSecurity(debug = true) +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + private final JwtLoginSuccessHandler jwtLoginSuccessHandler; + private final LoginFailureHandler loginFailureHandler; + private final JwtLogoutHandler jwtLogoutSuccessHandler; + private final ObjectMapper objectMapper; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .authorizeHttpRequests( + auth -> auth + .requestMatchers( "/**","/assets/**").permitAll() + .requestMatchers(HttpMethod.POST,"/api/users").permitAll() + .requestMatchers(HttpMethod.POST,"/api/auth/login").permitAll() + .requestMatchers(HttpMethod.POST,"/api/auth/logout").permitAll() + .requestMatchers(HttpMethod.GET,"/api/auth/csrf-token").permitAll() + .requestMatchers(HttpMethod.GET, "/actuator/**").permitAll() + .requestMatchers(HttpMethod.POST, "/api/auth/refresh").permitAll() + .requestMatchers(HttpMethod.GET, "/swagger-ui/**").permitAll() + + .requestMatchers(HttpMethod.POST,"/api/channels/public").hasAnyRole("CHANNEL_MANAGER") + .requestMatchers(HttpMethod.POST,"/api/channels/private").hasAnyRole("CHANNEL_MANAGER") + .requestMatchers(HttpMethod.PATCH,"/api/channels/").hasAnyRole("CHANNEL_MANAGER") + .requestMatchers(HttpMethod.DELETE,"/api/channels/").hasAnyRole("CHANNEL_MANAGER") + .requestMatchers(HttpMethod.PUT,"/api/auth/me").hasRole("ADMIN") + + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint((request, response, authException) -> { + if (request.getRequestURI().startsWith("/api/")) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write( + objectMapper.writeValueAsString(ErrorResponse.of(AuthErrorCode.FORBIDDEN)) + ); + } else { + response.sendRedirect("/"); + } + }) + ) + .csrf(csrf -> + csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())) + .formLogin(Customizer.withDefaults()) + .sessionManagement( + management -> management + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .formLogin(login -> login + .loginPage("/") + .loginProcessingUrl("/api/auth/login") + .failureUrl("/") + .successHandler(jwtLoginSuccessHandler) + .failureHandler(loginFailureHandler) + ) + .logout( + logout -> logout + .logoutUrl("/api/auth/logout") + .addLogoutHandler(jwtLogoutSuccessHandler) + ) + .build(); + } + + @Bean + public RoleHierarchy roleHierarchy() { +// RoleHierarchyImpl hierarchy = new RoleHierarchyImpl(); +// +// hierarchy.setHierarchy( +// "ROLE_ADMIN>ROLE_CHANNEL_MANAGER\n" + //한 줄단위로 하지 않는 경우 RoleHierarchyImpl이 파싱 제대로 못함 +// "ROLE_CHANNEL_MANAGER>ROLE_USER" +// ); +// return hierarchy; + + return RoleHierarchyImpl.withDefaultRolePrefix() + .role(Role.ADMIN.name()) + .implies(Role.CHANNEL_MANAGER.name(), Role.USER.name()) + + .role(Role.CHANNEL_MANAGER.name()) + .implies(Role.USER.name()) + .build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SessionRegistry sessionRegistry() { + return new SessionRegistryImpl(); + } + + @Bean + public HttpSessionEventPublisher httpSessionEventPublisher() { + return new HttpSessionEventPublisher(); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java b/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java new file mode 100644 index 000000000..d7041df14 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java @@ -0,0 +1,22 @@ +package com.sprint.mission.discodeit.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Bean + public MDCLoggingInterceptor mdcLoggingInterceptor() { + return new MDCLoggingInterceptor(); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + // 모든 경로에 적용 + registry.addInterceptor(mdcLoggingInterceptor()).addPathPatterns("/**"); + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/constant/AuthErrorCode.java b/src/main/java/com/sprint/mission/discodeit/constant/AuthErrorCode.java new file mode 100644 index 000000000..6f939bc47 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/constant/AuthErrorCode.java @@ -0,0 +1,20 @@ +package com.sprint.mission.discodeit.constant; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorCode { + + INVALID_USERNAME(401, "AUTH_002", "INVALID_USERNAME"), + INVALID_PASSWORD(401, "AUTH_002", "INVALID_PASSWORD"), + AUTHENTICATION_FAILED(401, "AUTH_003", "AUTHENTICATION_FAILED"), + FORBIDDEN(403, "AUTH_004", "FORBIDDEN"), + INVALID_USER(401, "AUTH_005", "INVALID_USER"); + + private final int status; + private final String code; + private final String message; + +} diff --git a/src/main/java/com/sprint/mission/discodeit/constant/BinaryContentErrorCode.java b/src/main/java/com/sprint/mission/discodeit/constant/BinaryContentErrorCode.java new file mode 100644 index 000000000..75a359a47 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/constant/BinaryContentErrorCode.java @@ -0,0 +1,18 @@ +package com.sprint.mission.discodeit.constant; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum BinaryContentErrorCode implements ErrorCode { + + + BINARY_CONTENT_NOT_FOUND(404, "BINARY_CONTENT_001", "binary content 없음"), + MULTIPART_FILE_CONVERT_FAILED(500, "BINARY_CONTENT_002", "MULTIPART_FILE_CONVERT_FAILED"), + UPLOAD_FAILED(500, "BINARY_CONTENT_003", "UPLOAD_FAILED"); + + private final int status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/sprint/mission/discodeit/constant/ChannelErrorCode.java b/src/main/java/com/sprint/mission/discodeit/constant/ChannelErrorCode.java new file mode 100644 index 000000000..e04fb3e3a --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/constant/ChannelErrorCode.java @@ -0,0 +1,21 @@ +package com.sprint.mission.discodeit.constant; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ChannelErrorCode implements ErrorCode { + + + CHANNEL_NOT_FOUND(404, "CHANNEL_001", "CHANNEL_NOT_FOUND"), + PRIVATE_CHANNEL_NOT_EDITABLE(403, "CHANNEL_002", "PRIVATECHANNEL_NOT_EDITABLE"), + PRIVATE_CHANNEL_DOES_NOT_HAVE_NAME(404, "CHANNEL_003", "PRIVATE_CHANNEL_DOES_NOT_HAVE_NAME"), + PRIVATE_CHANNEL_DOES_NOT_HAVE_DESCRIPTION(404,"CHANNEL_004","PRIVATE_CHANNEL_DOES_NOT_HAVE_DESCRIPTION"), + PRIVATE_CHANNEL_DOES_NOT_HAVE_NAME_AND_DESCRIPTION(404, "CHANNEL_005","PRIVATE_CHANNEL_DOES_NOT_HAVE_NAME_AND_DESCRIPTION"); + + private final int status; + private final String code; + private final String message; +} + diff --git a/src/main/java/com/sprint/mission/discodeit/constant/ClientRequestErrorCode.java b/src/main/java/com/sprint/mission/discodeit/constant/ClientRequestErrorCode.java new file mode 100644 index 000000000..75303737b --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/constant/ClientRequestErrorCode.java @@ -0,0 +1,22 @@ +package com.sprint.mission.discodeit.constant; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ClientRequestErrorCode implements ErrorCode { + + INVALID_INPUT_VALUE(400, "CLIENT_REQUEST_001", "유효하지 않은 입력 값"), + METHOD_ARGUMENT_TYPE_MISMATCH(400, "CLIENT_REQUEST_002", "요청 파라미터 타입 오류"), + MISSING_PARAMETER(400, "CLIENT_REQUEST_003", "필수 요청 파라미터가 누락"), + MISMATH_REQUEST_METHOD(400, "CLIENT_REQUEST_004", "요청 메소드 오류"), + VALIDATION_FAILED(400, "CLIENT_REQUEST_005", "유효하지 않은 요청 값"), + CONSTRAINT_VIOLATION(400, "CLIENT_REQUEST_006", "제약 조건을 위반"), + METHOD_NOT_ALLOWED(405, "CLIENT_REQUEST_007", "허용되지 않은 HTTP 메서드입니다"), + UKNOWN_ERROR(500, "CLIENT_REQUEST_008", "UNKNOWN_ERROR");; + + private final int status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/sprint/mission/discodeit/constant/ErrorCode.java b/src/main/java/com/sprint/mission/discodeit/constant/ErrorCode.java new file mode 100644 index 000000000..4a5d67048 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/constant/ErrorCode.java @@ -0,0 +1,7 @@ +package com.sprint.mission.discodeit.constant; + +public interface ErrorCode { + public int getStatus(); + public String getCode(); + public String getMessage(); +} diff --git a/src/main/java/com/sprint/mission/discodeit/constant/MessageErrorCode.java b/src/main/java/com/sprint/mission/discodeit/constant/MessageErrorCode.java new file mode 100644 index 000000000..9453e1a50 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/constant/MessageErrorCode.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.constant; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MessageErrorCode implements ErrorCode { + + MESSAGE_NOT_FOUND(404, "MESSAGE_001", "MESSAGE_NOT_FOUND"), + UPDATE_CONTENT_IS_NULL(400, "MESSAGE_002", "UPDATE_CONTENT_IS_NULL"); + + private final int status; + private final String code; + private final String message; + +} diff --git a/src/main/java/com/sprint/mission/discodeit/constant/NotificationErrorCode.java b/src/main/java/com/sprint/mission/discodeit/constant/NotificationErrorCode.java new file mode 100644 index 000000000..bd0207daa --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/constant/NotificationErrorCode.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.constant; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum NotificationErrorCode implements ErrorCode { + + NOTIFICATION_NOT_FOUND(404, "NOTIFICATION-001", "NOT_FOUND"), + NOT_AUTHENTICATED(401, "NOTIFICATION-002", "NOT_AUTHENTICATED"), + NOT_AUTHORIZED(403, "NOTIFICATION-003", "NOT_AUTHORIZED"); + + private final int status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/sprint/mission/discodeit/constant/ReadStatusErrorCode.java b/src/main/java/com/sprint/mission/discodeit/constant/ReadStatusErrorCode.java new file mode 100644 index 000000000..86109c179 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/constant/ReadStatusErrorCode.java @@ -0,0 +1,16 @@ +package com.sprint.mission.discodeit.constant; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ReadStatusErrorCode implements ErrorCode { + + READ_STATUS_NOT_FOUND(404, "READ_STATUS_001", "READ_STATUS_NOT_FOUND"), + READ_STATUS_ALREADY_EXIST(400, "READ_STATUS_002", "READ_STATUS_ALREADY_EXIST"); + + private final int status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/sprint/mission/discodeit/constant/TokenErrorCode.java b/src/main/java/com/sprint/mission/discodeit/constant/TokenErrorCode.java new file mode 100644 index 000000000..49c721c96 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/constant/TokenErrorCode.java @@ -0,0 +1,21 @@ +package com.sprint.mission.discodeit.constant; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TokenErrorCode implements ErrorCode { + + INVALID_TOKEN(401, "TOKEN_001", "토큰이 유효하지 않습니다."), + EXPIRED_TOKEN(401, "TOKEN_002", "토큰이 만료되었습니다."), + INVALID_REFRESH_TOKEN(401, "TOKEN_003", "리프레시 토큰이 유효하지 않습니다"), + INVALID_REFRESH_TOKEN_FORMAT(401, "TOKEN_003", "유효하지 않는 형식입니다."), + EXPIRED_REFRESH_TOKEN(401, "TOKEN_004", "리프레시 토큰이 만료되었습니다."), + TOKEN_CREATION_ERROR(500,"TOKEN_005", "토큰 생성 오류"); + + + private final int status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/sprint/mission/discodeit/constant/UserErrorCode.java b/src/main/java/com/sprint/mission/discodeit/constant/UserErrorCode.java new file mode 100644 index 000000000..ad21d4db6 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/constant/UserErrorCode.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.constant; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum UserErrorCode implements ErrorCode { + + USER_NOT_FOUND(404, "USER_001", "USER_NOT_FOUND"), + EMAIL_DUPLICATED(400, "USER_002", "EMAIL_DUPLICATED"), + USER_NAME_DUPLICATED(400, "USER_003", "USER_NAME_DUPLICATED"); + + private final int status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java index 796e29dbd..db8e2c738 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java @@ -1,25 +1,72 @@ package com.sprint.mission.discodeit.controller; -import com.sprint.mission.discodeit.dto.ApiResponse; -import com.sprint.mission.discodeit.dto.auth.request.SignIn; +import com.sprint.mission.discodeit.controller.api.AuthApi; +import com.sprint.mission.discodeit.dto.auth.request.RoleUpdateRequest; import com.sprint.mission.discodeit.dto.user.UserResponse; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import com.sprint.mission.discodeit.security.jwt.JwtDto; +import com.sprint.mission.discodeit.security.jwt.JwtInformation; +import com.sprint.mission.discodeit.security.jwt.JwtProvider; import com.sprint.mission.discodeit.service.AuthService; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Slf4j @RestController @RequiredArgsConstructor -@RequestMapping("/api") -public class AuthController { - +@RequestMapping("/api/auth") +public class AuthController implements AuthApi { private final AuthService authService; + private final JwtProvider jwtProvider; + + @GetMapping("csrf-token") + public ResponseEntity getCsrfToken(CsrfToken csrfToken) { + String tokenValue = csrfToken.getToken(); + log.debug("CSRF 토큰 요청: {}", tokenValue); + return ResponseEntity.status(HttpStatus.NON_AUTHORITATIVE_INFORMATION).build(); + } + + @GetMapping("/me") + public ResponseEntity me(@AuthenticationPrincipal DiscodeitUserDetails principal) { + return ResponseEntity.ok().body(principal.getUserResponse()); + } + + @PreAuthorize("hasRole('ADMIN')") + @PutMapping("/role") + public ResponseEntity updateRole(@Valid @RequestBody RoleUpdateRequest roleUpdateRequest) { + return ResponseEntity.ok().body(authService.updateRole(roleUpdateRequest)); + } + + @PostMapping("/refresh") + public ResponseEntity refresh(@CookieValue("REFRESH_TOKEN") String refreshToken, + HttpServletResponse response) { + log.info("토큰 리프레시 요청"); + JwtInformation jwtInformation = authService.refreshToken(refreshToken); + Cookie refreshCookie = jwtProvider.createRefreshTokenCookie( + jwtInformation.getRefreshToken()); + response.addCookie(refreshCookie); - @PostMapping( "/login") - public ResponseEntity> login(@RequestBody SignIn signIn) { - return ResponseEntity.ok().body(ApiResponse.onSuccess(authService.login(signIn))); + JwtDto body = new JwtDto( + jwtInformation.getUserResponse(), + jwtInformation.getAccessToken() + ); + return ResponseEntity + .status(HttpStatus.OK) + .body(body); } } diff --git a/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java b/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java index b955d5a2e..8549c4625 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/BinaryContentController.java @@ -1,6 +1,6 @@ package com.sprint.mission.discodeit.controller; -import com.sprint.mission.discodeit.dto.ApiResponse; +import com.sprint.mission.discodeit.controller.api.BinaryContentApi; import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponse; import com.sprint.mission.discodeit.service.BinaryContentService; import java.util.List; @@ -16,17 +16,25 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/binaryContents") -public class BinaryContentController { +public class BinaryContentController implements BinaryContentApi { private final BinaryContentService binaryContentService; + @Override + @GetMapping( "{binaryContentId}") + public ResponseEntity getBinaryContent(@PathVariable("binaryContentId") UUID binaryContentId) { + return ResponseEntity.ok().body(binaryContentService.findById(binaryContentId)); + } + + @Override @GetMapping - public ResponseEntity>> getBinaryContents(@RequestParam("ids") List binaryContentsIds) { - return ResponseEntity.ok().body(ApiResponse.onSuccess(binaryContentService.findAllByIdIn(binaryContentsIds))); + public ResponseEntity> getBinaryContents(@RequestParam("binaryContentId") List binaryContentsIds) { + return ResponseEntity.ok().body(binaryContentService.findAllByIdIn(binaryContentsIds)); } - @GetMapping( "{binaryContentId}") - public ResponseEntity> getBinaryContent(@PathVariable("binaryContentId") UUID binaryContentId) { - return ResponseEntity.ok().body(ApiResponse.onSuccess(binaryContentService.findById(binaryContentId))); + @Override + @GetMapping("/{binaryContentId}/download") + public ResponseEntity download(@PathVariable("binaryContentId") UUID binaryContentId) { + return binaryContentService.download(binaryContentService.findById(binaryContentId)); } } diff --git a/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java index c09b32a8a..5ff2699a7 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java @@ -1,15 +1,18 @@ package com.sprint.mission.discodeit.controller; -import com.sprint.mission.discodeit.dto.ApiResponse; +import com.sprint.mission.discodeit.controller.api.ChannelApi; import com.sprint.mission.discodeit.dto.channel.ChannelResponse; import com.sprint.mission.discodeit.dto.channel.request.ChannelCreateRequest; import com.sprint.mission.discodeit.dto.channel.request.ChannelUpdateRequest; import com.sprint.mission.discodeit.dto.channel.request.PrivateChannelCreateRequest; import com.sprint.mission.discodeit.service.ChannelService; +import jakarta.validation.Valid; import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -17,44 +20,54 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor @RequestMapping("/api") -public class ChannelController { +public class ChannelController implements ChannelApi { private final ChannelService channelService; - @PostMapping( value = "/channels", params = "type=public") - public ResponseEntity> createPublicChannel(@RequestBody ChannelCreateRequest request) { - return ResponseEntity.ok().body(ApiResponse.onSuccess(channelService.createPublicChannel(request.toServiceRequest()))); + @Override + @PreAuthorize("hasRole('CHANNEL_MANAGER')") + @PostMapping( "/channels/public") + public ResponseEntity createPublicChannel(@Valid @RequestBody ChannelCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(channelService.createPublicChannel(request.toServiceRequest())); } - @PostMapping(value = "/channels", params = "type=private") - public ResponseEntity> createPrivateChannel(@RequestBody PrivateChannelCreateRequest request) { - return ResponseEntity.ok().body(ApiResponse.onSuccess(channelService.createPrivateChannel(request.toServiceRequest()))); + @Override + @PreAuthorize("hasRole('CHANNEL_MANAGER')") + @PostMapping("/channels/private") + public ResponseEntity createPrivateChannel(@RequestBody PrivateChannelCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(channelService.createPrivateChannel(request.toServiceRequest())); } + @Override @GetMapping( "/channels/{channelId}") - public ResponseEntity> findChannel(@PathVariable("channelId") UUID channelId) { - return ResponseEntity.ok().body(ApiResponse.onSuccess(channelService.findChannelById(channelId))); + public ResponseEntity findChannelById(@PathVariable("channelId") UUID channelId) { + return ResponseEntity.ok().body(channelService.findChannelById(channelId)); } - @GetMapping( "/users/{userId}/channels") - public ResponseEntity>> findChannelsWhichUserJoined(@PathVariable("userId") UUID userId) { - return ResponseEntity.ok().body(ApiResponse.onSuccess(channelService.findAllChannelsByUserId(userId))); + @Override + @GetMapping( "/channels") + public ResponseEntity> findAllChannelsByUserId(@RequestParam("userId") UUID userId) { + return ResponseEntity.ok().body(channelService.findAllChannelsByUserId(userId)); } + @Override + @PreAuthorize("hasRole('CHANNEL_MANAGER')") @PatchMapping("/channels/{channelId}") - public ResponseEntity> updateChannel(@PathVariable("channelId") UUID channelId, @RequestBody ChannelUpdateRequest request) { - return ResponseEntity.ok().body(ApiResponse.onSuccess(channelService.updateChannel(request.toServiceRequest(channelId)))); + public ResponseEntity updateChannel(@PathVariable("channelId") UUID channelId, @RequestBody ChannelUpdateRequest request) { + return ResponseEntity.ok().body(channelService.updateChannel(request.toServiceRequest(channelId))); } + @Override + @PreAuthorize("hasRole('CHANNEL_MANAGER')") @DeleteMapping( "/channels/{channelId}") - public ResponseEntity> deleteChannel(@PathVariable("channelId") UUID channelId) { + public ResponseEntity deleteChannel(@PathVariable("channelId") UUID channelId) { channelService.deleteChannel(channelId); - return ResponseEntity.ok().body(ApiResponse.onSuccess(null)); + return ResponseEntity.noContent().build(); } - } diff --git a/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java index e6bf958f2..0c2fa8748 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java @@ -1,14 +1,23 @@ package com.sprint.mission.discodeit.controller; -import com.sprint.mission.discodeit.dto.ApiResponse; +import com.sprint.mission.discodeit.controller.api.MessageApi; +import com.sprint.mission.discodeit.dto.PageResponse; import com.sprint.mission.discodeit.dto.message.MessageResponse; import com.sprint.mission.discodeit.dto.message.request.MessageCreateRequest; import com.sprint.mission.discodeit.dto.message.request.MessageUpdateRequest; import com.sprint.mission.discodeit.service.MessageService; +import io.micrometer.core.annotation.Timed; +import java.time.Instant; import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -16,34 +25,54 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; @RestController @RequiredArgsConstructor -@RequestMapping("/api") -public class MessageController { +@RequestMapping("/api/messages") +public class MessageController implements MessageApi { private final MessageService messageService; - @PostMapping("/messages") - public ResponseEntity> createMessage(@RequestBody MessageCreateRequest request) { - return ResponseEntity.ok().body(ApiResponse.onSuccess(messageService.createMessage(request.toServiceRequest()))); + @Override + @Timed("message.create.async") + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity createMessage( + @RequestPart("messageCreateRequest") MessageCreateRequest request, + @RequestPart(value = "attachments", required = false) List attachments + ) { + return ResponseEntity.status(HttpStatus.CREATED).body(messageService.createMessage(request.toServiceRequest(attachments))); } - @PatchMapping( "/messages/{messageId}") - public ResponseEntity> updateMessage(@PathVariable("messageId") UUID messageId, @RequestBody MessageUpdateRequest request) { - return ResponseEntity.ok().body(ApiResponse.onSuccess(messageService.updateContent(request.toServiceRequest(messageId)))); + @Override + @PreAuthorize("hasRole('USER') and @messageService.isAuthor(authentication.principal.userResponse.id, #messageId) == true") + @PatchMapping( "/{messageId}") + public ResponseEntity updateMessage(@PathVariable("messageId") UUID messageId, @RequestBody MessageUpdateRequest request) { + return ResponseEntity.ok().body(messageService.updateContent(request.toServiceRequest(messageId))); } - @DeleteMapping( "/messages/{messageId}") - public ResponseEntity> deleteMessage(@PathVariable("messageId") UUID messageId) { + @Override + @PreAuthorize("hasRole('USER') and @messageService.isAuthor(authentication.principal.userResponse.id, #messageId) == true") + @DeleteMapping( "/{messageId}") + public ResponseEntity deleteMessage(@PathVariable("messageId") UUID messageId) { messageService.deleteMessage(messageId); - return ResponseEntity.ok().body( ApiResponse.onSuccess(null)); + return ResponseEntity.noContent().build(); } - @GetMapping( "/channels/{channelId}/messages") - public ResponseEntity>> findMessagesByChannelId(@PathVariable("channelId") UUID channelId) { - return ResponseEntity.ok().body(ApiResponse.onSuccess(messageService.findMessagesByChannelId(channelId))); + // API 스펙 요규사항에 따른 설계 더 restful 한 api 설계 가능 + @Override + @GetMapping( params = "channelId") + public ResponseEntity> findMessagesByChannelId(@RequestParam("channelId") UUID channelId, + @RequestParam(value = "cursor", required = false) Instant cursor, + @PageableDefault( + size = 50, + page = 0, + sort = "createdAt", + direction = Direction.DESC + ) Pageable pageable) { + return ResponseEntity.ok().body(messageService.findMessagesByChannelId(channelId, cursor, pageable)); } - } diff --git a/src/main/java/com/sprint/mission/discodeit/controller/NotificationController.java b/src/main/java/com/sprint/mission/discodeit/controller/NotificationController.java new file mode 100644 index 000000000..56775d517 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/NotificationController.java @@ -0,0 +1,38 @@ +package com.sprint.mission.discodeit.controller; + +import com.sprint.mission.discodeit.dto.notification.NotificationDto; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import com.sprint.mission.discodeit.service.NotificationService; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications") +public class NotificationController { + + private final NotificationService notificationService; + + @GetMapping + public ResponseEntity> getNotifications( + @AuthenticationPrincipal DiscodeitUserDetails userDetails) { + UUID userId = userDetails.getUserResponse().getId(); + return ResponseEntity.ok(notificationService.getNotifications(userId)); + } + + @DeleteMapping("/{notificationid}") + public ResponseEntity deleteNotification(@PathVariable UUID notificationId, @AuthenticationPrincipal DiscodeitUserDetails userDetails) { + UUID userId = userDetails.getUserResponse().getId(); + notificationService.deleteNotification(notificationId, userDetails.getUserResponse().getId()); + return ResponseEntity.noContent().build(); + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java index fc0d9a35b..de79d7e31 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java @@ -1,6 +1,6 @@ package com.sprint.mission.discodeit.controller; -import com.sprint.mission.discodeit.dto.ApiResponse; +import com.sprint.mission.discodeit.controller.api.ReadStatusApi; import com.sprint.mission.discodeit.dto.readstatus.ReadStatusResponse; import com.sprint.mission.discodeit.dto.readstatus.request.ReadStatusCreateRequest; import com.sprint.mission.discodeit.dto.readstatus.request.ReadStatusUpdateRequest; @@ -8,6 +8,7 @@ import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -15,27 +16,31 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor -@RequestMapping("/api") -public class ReadStatusController { +@RequestMapping("/api/readStatuses") +public class ReadStatusController implements ReadStatusApi { private final ReadStatusService readStatusService; - @PostMapping("/readStatuses") - public ResponseEntity> createReadStatuses(@RequestBody ReadStatusCreateRequest reqeust) { - return ResponseEntity.ok().body(ApiResponse.onSuccess(readStatusService.createReadStatus(reqeust.toServiceRequest()))); + @Override + @PostMapping + public ResponseEntity createReadStatus(@RequestBody ReadStatusCreateRequest reqeust) { + return ResponseEntity.status(HttpStatus.CREATED).body(readStatusService.createReadStatus(reqeust.toServiceRequest())); } - @PatchMapping("/readStatuses/{readStatusId}") - public ResponseEntity> updateChannelReadStatus(@PathVariable("readStatusId") UUID readStatusId, @RequestBody ReadStatusUpdateRequest reqeust) { - return ResponseEntity.ok().body(ApiResponse.onSuccess(readStatusService.updateReadStatus(reqeust.toServiceRequest(readStatusId)))); + @Override + @PatchMapping("/{readStatusId}") + public ResponseEntity updateReadStatus(@PathVariable("readStatusId") UUID readStatusId, @RequestBody ReadStatusUpdateRequest reqeust) { + return ResponseEntity.ok().body(readStatusService.updateReadStatus(reqeust.toServiceRequest(readStatusId))); } - @GetMapping("/users/{userId}/readStatuses") - public ResponseEntity>> getUserReadStatuses(@PathVariable("userId") UUID userId) { - return ResponseEntity.ok().body(ApiResponse.onSuccess(readStatusService.findAllByUserId(userId))); + @Override + @GetMapping + public ResponseEntity> findAllReadStatusByUserId(@RequestParam("userId") UUID userId) { + return ResponseEntity.ok().body(readStatusService.findAllByUserId(userId)); } } diff --git a/src/main/java/com/sprint/mission/discodeit/controller/UserController.java b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java index 17b982f12..fbdc95721 100644 --- a/src/main/java/com/sprint/mission/discodeit/controller/UserController.java +++ b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java @@ -1,6 +1,6 @@ package com.sprint.mission.discodeit.controller; -import com.sprint.mission.discodeit.dto.ApiResponse; +import com.sprint.mission.discodeit.controller.api.UserApi; import com.sprint.mission.discodeit.dto.user.UserResponse; import com.sprint.mission.discodeit.dto.user.request.UserCreateRequest; import com.sprint.mission.discodeit.dto.user.request.UserUpdateRequest; @@ -9,47 +9,66 @@ import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; @RestController @RequiredArgsConstructor @RequestMapping("/api/users") -public class UserController { +public class UserController implements UserApi { private final UserService userService; + @Override @GetMapping - public ResponseEntity>> findUsers() { - return ResponseEntity.ok().body(ApiResponse.onSuccess(userService.findUsers())); + public ResponseEntity> findUsers() { + return ResponseEntity.ok().body(userService.findUsers()); } - @PostMapping - public ResponseEntity> createUser(@Valid @ModelAttribute UserCreateRequest request) { - return ResponseEntity.ok().body(ApiResponse.onSuccess(userService.createUser(request.toServiceRequest()))); + @Override + @PostMapping( + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity createUser(@Valid @RequestPart("userCreateRequest") UserCreateRequest request, + @RequestPart(value = "profile", required = false) + MultipartFile profile) { + return ResponseEntity.status(HttpStatus.CREATED).body(userService.createUser(request.toServiceRequest(profile))); } + @Override @GetMapping( "/{userId}") - public ResponseEntity> findUser(@PathVariable("userId") UUID userId) { - return ResponseEntity.ok().body(ApiResponse.onSuccess(userService.findUserById(userId))); + public ResponseEntity findUser(@PathVariable("userId") UUID userId) { + return ResponseEntity.ok().body(userService.findUserById(userId)); } + @Override + @PreAuthorize("hasRole('USER') and #userId == authentication.principal.userResponse.id") @DeleteMapping("/{userId}") - public ResponseEntity> deleteUser(@PathVariable("userId") UUID userId) { + public ResponseEntity deleteUser(@PathVariable("userId") UUID userId) { userService.deleteUser(userId); - return ResponseEntity.ok().body(ApiResponse.onSuccess(null)); + return ResponseEntity.noContent().build(); } + @Override + @PreAuthorize("hasRole('USER') and #userId == authentication.principal.userResponse.id") @PatchMapping("/{userId}") - public ResponseEntity> updateUser(@PathVariable("userId") UUID userId, @Valid @ModelAttribute UserUpdateRequest request) { - return ResponseEntity.ok().body(ApiResponse.onSuccess(userService.updateUser(request.toServiceRequest(userId)))); + public ResponseEntity updateUser( + @PathVariable("userId") UUID userId, + @RequestPart("userUpdateRequest") UserUpdateRequest userUpdateRequest, + @RequestPart(value = "profile", required = false) MultipartFile profile + ) { + return ResponseEntity.ok().body(userService.updateUser(userUpdateRequest.toServiceRequest(userId, profile))); } - } diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java new file mode 100644 index 000000000..7db0f6520 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/AuthApi.java @@ -0,0 +1,8 @@ +package com.sprint.mission.discodeit.controller.api; + +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Auth", description = "인증 API") +public interface AuthApi { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java new file mode 100644 index 000000000..42a09d19c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/BinaryContentApi.java @@ -0,0 +1,53 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.UUID; +import org.springframework.http.ResponseEntity; + +@Tag(name = "BinaryContent", description = "첨부 파일 API") +public interface BinaryContentApi { + + @Operation(summary = "첨부 파일 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "첨부 파일 조회 성공", + content = @Content(schema = @Schema(implementation = BinaryContentResponse.class)) + ), + @ApiResponse( + responseCode = "404", description = "첨부 파일을 찾을 수 없음" + ) + }) + ResponseEntity getBinaryContent( + @Parameter(description = "조회할 첨부 파일 ID") UUID binaryContentId + ); + + @Operation(summary = "여러 첨부 파일 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "첨부 파일 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = BinaryContentResponse.class))) + ) + }) + ResponseEntity> getBinaryContents( + @Parameter(description = "조회할 첨부 파일 ID 목록") List binaryContentsIds); + + @Operation(summary = "파일 다운로드") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "파일 다운로드 성공", + content = @Content(schema = @Schema(type = "string", format = "binary")) + ) + }) + ResponseEntity download ( + @Parameter(description = "다운로드할 파일 ID") UUID binaryContentId); + +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java new file mode 100644 index 000000000..cce485e8b --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/ChannelApi.java @@ -0,0 +1,107 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.channel.ChannelResponse; +import com.sprint.mission.discodeit.dto.channel.request.ChannelCreateRequest; +import com.sprint.mission.discodeit.dto.channel.request.ChannelUpdateRequest; +import com.sprint.mission.discodeit.dto.channel.request.PrivateChannelCreateRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.UUID; +import org.springframework.http.ResponseEntity; + +@Tag(name = "Channel", description = "Channel API") +public interface ChannelApi { + + @Operation(summary = "Public Channel 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "Public Channel이 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = ChannelResponse.class)) + ) + }) + ResponseEntity createPublicChannel( + @Parameter( + description = "Public Channel 생성 요청") ChannelCreateRequest request + ); + + + @Operation(summary = "Private Channel 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "Private Channel이 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = ChannelResponse.class)) + ) + }) + ResponseEntity createPrivateChannel( + @Parameter( + description = "Private Channel 생성 요청") PrivateChannelCreateRequest request + ); + + + @Operation(summary = "Channel 정보 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Channel 정보가 성공적으로 수정됨", + content = @Content(schema = @Schema(implementation = ChannelResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "Channel을 찾을 수 없음" + ), + @ApiResponse( + responseCode = "404", + description = "Private Channel은 수정할 수 없음" + ) + }) + ResponseEntity updateChannel( + @Parameter(description = "수정할 Channel ID") UUID channelId, + @Parameter(description = "수정할 Channel 정보") ChannelUpdateRequest request + ); + + + @Operation(summary = "Channel 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Channel 조회 성공", + content = @Content(schema = @Schema(implementation = ChannelResponse.class)) + ), + @ApiResponse( + responseCode = "404", description = "Channel을 찾을 수 없음" + ) + }) + ResponseEntity findChannelById(@Parameter(description = "Channel Id") UUID channelId); + + + @Operation(summary = "User가 참여 중인 Channel 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Channel 목록 조회 성공", + content = @Content(schema = @Schema(implementation = ChannelResponse.class)) + ) + }) + ResponseEntity> findAllChannelsByUserId(@Parameter(description = "유저 Id") UUID userId); + + + @Operation(summary = "Channel 삭제") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "Channel을 찾을 수 없음" + ), + @ApiResponse( + responseCode = "404", description = "Channel not found" + ) + }) + ResponseEntity deleteChannel(@Parameter(description = "삭제할 Channel Id") UUID channelId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java new file mode 100644 index 000000000..963705319 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/MessageApi.java @@ -0,0 +1,92 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.PageResponse; +import com.sprint.mission.discodeit.dto.message.MessageResponse; +import com.sprint.mission.discodeit.dto.message.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.message.request.MessageUpdateRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "Message", description = "Message API") +public interface MessageApi { + + @Operation(summary = "Message 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", description = "Message 생성 성공", + content = @Content(schema = @Schema(implementation = MessageResponse.class)) + ), + @ApiResponse( + responseCode = "404", description = "Channel 또는 User를 찾을 수 없음" + ) + + }) + ResponseEntity createMessage( + @Parameter( + description = "Message 생성 정보", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE) + ) MessageCreateRequest request, + @Parameter( + description = "Message 첨부 파일들", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) List attachments + ); + + + @Operation(summary = "Message 내용 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", description = "Message가 성공적으로 수정됨", + content = @Content(schema = @Schema(implementation = MessageResponse.class)) + ), + @ApiResponse( + responseCode = "404", description = "Message를 찾을 수 없음" + ) + }) + ResponseEntity updateMessage(@Parameter(description = "수정할 Message ID") UUID messageId, + @Parameter(description = "수정할 Message 내용") MessageUpdateRequest request); + + + @Operation(summary = "Message 삭제") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "Message가 성공적으로 삭제됨" + ), + @ApiResponse( + responseCode = "404", description = "Message를 찾을 수 없음" + ) + }) + ResponseEntity deleteMessage(@Parameter(description = "Message Id") UUID messageId); + + + @Operation(summary = "Channel의 Message 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Channel의 Message 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = MessageResponse.class))) + ), + @ApiResponse( + responseCode = "404", description = "Channel not found" + ) + }) + ResponseEntity> findMessagesByChannelId( + @Parameter(description = "조회할 Channel Id") UUID channelId, + @Parameter(description = "페이징 커서 정보") Instant cursor, + @Parameter(description = "페이징 정보") Pageable pageable + ); +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java new file mode 100644 index 000000000..a0d7cd9df --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/ReadStatusApi.java @@ -0,0 +1,67 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.readstatus.ReadStatusResponse; +import com.sprint.mission.discodeit.dto.readstatus.request.ReadStatusCreateRequest; +import com.sprint.mission.discodeit.dto.readstatus.request.ReadStatusUpdateRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.UUID; +import org.springframework.http.ResponseEntity; + +@Tag(name = "ReadStatus", description = "Message 읽음 상태 API") +public interface ReadStatusApi { + + @Operation(summary = "Message 읽음 상태 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "Message 읽음 상태가 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = ReadStatusResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "Channel 또는 User를 찾을 수 없음" + ), + @ApiResponse( + responseCode = "400", + description = "이미 읽음 상태가 존재함" + ) + + }) + ResponseEntity createReadStatus( + @Parameter(description = "ReadStatus 생성 요청") ReadStatusCreateRequest request + ); + + @Operation(summary = "메시지 읽음상태 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Message 읽음 상태 수정 성공", + content = @Content(schema = @Schema(implementation = ReadStatusResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "Message 읽음 상태를 찾을 수 없음" + ) + }) + ResponseEntity updateReadStatus(@Parameter(description = "수정할 읽음 상태 ID") UUID readStatusId, + @Parameter(description = "수정할 읽음 상태 정보") ReadStatusUpdateRequest request); + + @Operation(summary = "User의 Message 메시지 읽음 상태 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Message 읽음 상태 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ReadStatusResponse.class))) + ) + }) + ResponseEntity> findAllReadStatusByUserId( + @Parameter(description = "조회할 User Id") UUID userId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java b/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java new file mode 100644 index 000000000..6d3931aaf --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/controller/api/UserApi.java @@ -0,0 +1,112 @@ +package com.sprint.mission.discodeit.controller.api; + +import com.sprint.mission.discodeit.dto.user.UserResponse; +import com.sprint.mission.discodeit.dto.user.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.user.request.UserUpdateRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "User", description = "User API") +public interface UserApi { + + @Operation(summary = "User 생성") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "User가 성공적으로 생성됨", + content = @Content(schema = @Schema(implementation = UserResponse.class)) + ), + @ApiResponse( + responseCode = "400", + description = "같은 email 또는 username를 사용하는 User가 이미 존재함" + ) + }) + ResponseEntity createUser( + @Parameter( + description = "User 생성 요청", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) UserCreateRequest userCreateRequest, + @Parameter( + description = "프로필 이미지", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) MultipartFile profile); + + + @Operation(summary = "User 정보 수정") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "User 정보가 성공적으로 수정됨", + content = @Content(schema = @Schema(implementation = UserResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "User를 찾을 수 없음" + ), + @ApiResponse( + responseCode = "400", + description = "같은 email 또는 username를 사용하는 User가 이미 존재함" + ) + }) + ResponseEntity updateUser( + @Parameter(description = "수정할 User Id") UUID userId, + @Parameter( + description = "수정할 User 정보", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) UserUpdateRequest userCreateRequest, + @Parameter( + description = "수정할 User 프로필 이미지", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) MultipartFile profile); + + + @Operation(summary = "User 삭제") + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "User가 성공적으로 삭제됨", + content = @Content(schema = @Schema(implementation = UserResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "User를 찾을 수 없음" + ) + }) + ResponseEntity deleteUser(@Parameter(description = "삭제할 User Id") UUID userId); + + + @Operation(summary = "User 정보 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "User 정보 조회 성공", + content = @Content(schema = @Schema(implementation = UserResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "User not found" + ) + }) + ResponseEntity findUser(@Parameter(description = "조회할 User Id") UUID userId); + + @Operation(summary = "전체 User 목록 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "User 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserResponse.class)) + )) + }) + ResponseEntity> findUsers(); +} diff --git a/src/main/java/com/sprint/mission/discodeit/decorator/MdcTaskDecorator.java b/src/main/java/com/sprint/mission/discodeit/decorator/MdcTaskDecorator.java new file mode 100644 index 000000000..cbbe66566 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/decorator/MdcTaskDecorator.java @@ -0,0 +1,30 @@ +package com.sprint.mission.discodeit.decorator; + +import java.util.Map; +import org.slf4j.MDC; +import org.springframework.core.task.TaskDecorator; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component +public class MdcTaskDecorator implements TaskDecorator { + + @Override + public Runnable decorate(Runnable runnable) { + SecurityContext securityContext = SecurityContextHolder.getContext(); + Map contextMap = MDC.getCopyOfContextMap(); + return () -> { + try { + SecurityContextHolder.setContext(securityContext); + if (contextMap != null) { + MDC.setContextMap(contextMap); + } + runnable.run(); + } finally { + SecurityContextHolder.clearContext(); + MDC.clear(); + } + }; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/ApiResponse.java b/src/main/java/com/sprint/mission/discodeit/dto/ApiResponse.java deleted file mode 100644 index 016335c28..000000000 --- a/src/main/java/com/sprint/mission/discodeit/dto/ApiResponse.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.sprint.mission.discodeit.dto; - -import com.fasterxml.jackson.annotation.JsonPropertyOrder; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@JsonPropertyOrder({"code", "message", "data"}) -public class ApiResponse { - - private final int code; - private final String message; - private T data; - - public static ApiResponse onSuccess(T data) { - return new ApiResponse<>(HttpStatus.OK, data); - } - - public static ApiResponse onFailure(T data) { - return new ApiResponse<>(HttpStatus.BAD_REQUEST, data); - } - - private ApiResponse(HttpStatus httpStatus, T data) { - this.code = httpStatus.value(); - this.message = httpStatus.name(); - this.data = data; - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/PageResponse.java b/src/main/java/com/sprint/mission/discodeit/dto/PageResponse.java new file mode 100644 index 000000000..702c6b50c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/PageResponse.java @@ -0,0 +1,17 @@ +package com.sprint.mission.discodeit.dto; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class PageResponse { + private int size; + private boolean hasNext; + private List content; + private Object nextCursor; + private Long totalElements; +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/auth/request/RoleUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/auth/request/RoleUpdateRequest.java new file mode 100644 index 000000000..2591ede27 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/auth/request/RoleUpdateRequest.java @@ -0,0 +1,19 @@ +package com.sprint.mission.discodeit.dto.auth.request; + +import com.sprint.mission.discodeit.entity.Role; +import jakarta.validation.constraints.NotNull; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class RoleUpdateRequest { + @NotNull(message = "userId는 null이면 안됨") + private UUID userId; + + @NotNull(message = "role은 null이면 안됨") + private Role newRole; +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/auth/request/SignIn.java b/src/main/java/com/sprint/mission/discodeit/dto/auth/request/SignIn.java deleted file mode 100644 index d0ae1905c..000000000 --- a/src/main/java/com/sprint/mission/discodeit/dto/auth/request/SignIn.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.sprint.mission.discodeit.dto.auth.request; - -import jakarta.validation.constraints.NotBlank; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public class SignIn { - - @NotBlank(message = "이메일은 필수입니다.") - private String email; - - @NotBlank(message = "비밀번호는 필수입니다.") - private String password; - -} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/binarycontent/BinaryContentResponse.java b/src/main/java/com/sprint/mission/discodeit/dto/binarycontent/BinaryContentResponse.java index cc26605c6..b5dcdf416 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/binarycontent/BinaryContentResponse.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/binarycontent/BinaryContentResponse.java @@ -1,35 +1,38 @@ package com.sprint.mission.discodeit.dto.binarycontent; import com.sprint.mission.discodeit.entity.BinaryContent; -import java.util.Arrays; +import com.sprint.mission.discodeit.entity.BinaryContentStatus; import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor public class BinaryContentResponse { - private UUID binaryContentId; + private UUID id; private String fileName; - private String extension; + private Long size; - private byte[] data; + private String contentType; + + private BinaryContentStatus status; + + private byte[] bytes; public BinaryContentResponse(BinaryContent binaryContent) { - this.binaryContentId = binaryContent.getMessageId(); + this.id = binaryContent.getId(); this.fileName = binaryContent.getFileName(); - this.extension = binaryContent.getFileType().getExtension(); - this.data = binaryContent.getData(); + this.contentType = binaryContent.getContentType(); + this.bytes = binaryContent.getBytes(); + this.size = binaryContent.getSize(); + this.status = binaryContent.getStatus(); } - @Override - public String toString() { - return "BinaryContentResponse{" + - "binaryContentId=" + binaryContentId + - ", fileName='" + fileName + '\'' + - ", extension='" + extension + '\'' + - ", data=" + Arrays.toString(data) + - '}'; - } } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/binarycontent/request/BinaryContentCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/binarycontent/request/BinaryContentCreateRequest.java index 98ca8550d..4baeca4d4 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/binarycontent/request/BinaryContentCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/binarycontent/request/BinaryContentCreateRequest.java @@ -4,25 +4,30 @@ import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.validator.constraints.Length; @Getter @AllArgsConstructor +@NoArgsConstructor public class BinaryContentCreateRequest { + @Length(min = 1, max = 255, message = "파일 이름은 1 ~ 255자 이어야 함") @NotBlank(message = "fileName는 null이거나 공백이면 안됨") - String fileName; + private String fileName; - @NotBlank(message = "fileType는 null이거나 공백이면 안됨") - String fileType; + @Length(min = 1, max = 100, message = "contentTyped은 1 ~ 100자 이어야 함") + @NotNull(message = "contentType는 null이면 안됨") + private String contentType; @NotNull(message = "data는 null이면 안됨") - byte[] data; + private byte[] bytes; public BinaryContentCreateServiceRequest toServiceRequest() { return BinaryContentCreateServiceRequest.builder() .fileName(fileName) - .fileType(fileType) - .data(data) + .contentType(contentType) + .bytes(bytes) .build(); } } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/binarycontent/request/BinaryContentCreateServiceRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/binarycontent/request/BinaryContentCreateServiceRequest.java index b57b3a166..1450c1f30 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/binarycontent/request/BinaryContentCreateServiceRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/binarycontent/request/BinaryContentCreateServiceRequest.java @@ -1,7 +1,6 @@ package com.sprint.mission.discodeit.dto.binarycontent.request; import com.sprint.mission.discodeit.entity.BinaryContent; -import com.sprint.mission.discodeit.entity.FileType; import lombok.Builder; import lombok.Getter; @@ -10,14 +9,14 @@ public class BinaryContentCreateServiceRequest { String fileName; - String fileType; - byte[] data; + String contentType; + byte[] bytes; - public BinaryContent toEntity(FileType fileType) { + public BinaryContent toEntity() { return BinaryContent.builder() .fileName(fileName) - .fileType(fileType) - .data(data) + .contentType(contentType) + .bytes(bytes) .build(); } } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/channel/ChannelResponse.java b/src/main/java/com/sprint/mission/discodeit/dto/channel/ChannelResponse.java index 9270e5853..1cf30ec30 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/channel/ChannelResponse.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/channel/ChannelResponse.java @@ -1,41 +1,23 @@ package com.sprint.mission.discodeit.dto.channel; -import com.sprint.mission.discodeit.dto.message.MessageResponse; -import com.sprint.mission.discodeit.entity.Channel; -import com.sprint.mission.discodeit.entity.Message; -import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.dto.user.UserResponse; +import com.sprint.mission.discodeit.entity.ChannelType; import java.time.Instant; import java.util.List; -import java.util.Set; import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; @Getter +@Builder +@AllArgsConstructor public class ChannelResponse { - protected final List messages; - protected final List users; - protected final UUID id; - protected final Instant createdAt; - protected final Instant lastMessageAt; - - protected ChannelResponse(Channel channel) { - this.id = channel.getId(); - this.createdAt = channel.getCreatedAt(); - this.lastMessageAt = getLastMessageAt(channel.getMessages()); - this.messages = toMessageResponses(channel.getMessages()); - this.users = toUserIds(channel.getReadStatuses()); - } - - protected static List toMessageResponses(Set messages) { - return messages.stream().map(MessageResponse::new).toList(); - } - - protected static List toUserIds(Set readStatuses) { - return readStatuses.stream().map(ReadStatus::getUserId).toList(); - } - - protected static Instant getLastMessageAt(Set messages) { - return messages.stream().map(Message::getCreatedAt).max(Instant::compareTo).orElse(null); - } + private final UUID id; + private final ChannelType type; + private String name; + private String description; + private final List participants; + private final Instant lastMessageAt; } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/channel/PrivateChannelResponse.java b/src/main/java/com/sprint/mission/discodeit/dto/channel/PrivateChannelResponse.java deleted file mode 100644 index 2c415a71c..000000000 --- a/src/main/java/com/sprint/mission/discodeit/dto/channel/PrivateChannelResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.sprint.mission.discodeit.dto.channel; - -import com.sprint.mission.discodeit.entity.Channel; -import lombok.Getter; - -@Getter -public class PrivateChannelResponse extends ChannelResponse { - - public PrivateChannelResponse(Channel channel) { - super(channel); - } - - @Override - public String toString() { - return "PrivateChannelResponse{" + - "createdAt=" + createdAt + - ", id=" + id + - ", lastMessageAt=" + lastMessageAt + - ", messages=" + messages + - ", users=" + users + - '}'; - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/channel/PublicChannelResponse.java b/src/main/java/com/sprint/mission/discodeit/dto/channel/PublicChannelResponse.java deleted file mode 100644 index c0a0000a0..000000000 --- a/src/main/java/com/sprint/mission/discodeit/dto/channel/PublicChannelResponse.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.sprint.mission.discodeit.dto.channel; - -import com.sprint.mission.discodeit.entity.Channel; -import lombok.Getter; - -@Getter -public class PublicChannelResponse extends ChannelResponse { - private String channelName; - private String description; - - public PublicChannelResponse(Channel channel) { - super(channel); - this.channelName = channel.getChannelName(); - this.description = channel.getDescription(); - } - - @Override - public String toString() { - return "PublicChannelResponse{" + - "channelName='" + channelName + '\'' + - ", description='" + description + '\'' + - ", createdAt=" + createdAt + - ", id=" + id + - ", lastMessageAt=" + lastMessageAt + - ", messages=" + messages + - ", users=" + users + - '}'; - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/channel/request/ChannelCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/channel/request/ChannelCreateRequest.java index 7ce178f08..f3bb31a12 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/channel/request/ChannelCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/channel/request/ChannelCreateRequest.java @@ -1,33 +1,28 @@ package com.sprint.mission.discodeit.dto.channel.request; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.validator.constraints.Length; @Getter +@NoArgsConstructor @AllArgsConstructor public class ChannelCreateRequest { + @Length(min = 1 , max = 100, message = "채널 이름은 1 ~ 100자 이어야함") @NotBlank(message = "채널 이름은 null이거나 공백이면 안됨") - private final String channelName; + private String name; + @Length(min = 1, max = 500 , message = "채널 설명은 1 ~ 500자 이어야함") @NotBlank(message = "채널 설명은 null이면 안됨") - private final String description; - - @NotNull(message = "유저은 null이면 안됨") - private final UUID hostId; - - @NotBlank(message = "채널 타입 코드은 null이나 공백이면 안됨") - private final String channelTypeCode; + private String description; public ChannelCreateServiceRequest toServiceRequest() { return ChannelCreateServiceRequest.builder() - .channelName(channelName) + .name(name) .description(description) - .hostId(hostId) - .channelTypeCode(channelTypeCode) .build(); } } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/channel/request/ChannelCreateServiceRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/channel/request/ChannelCreateServiceRequest.java index dfa846d65..ae17c3c8f 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/channel/request/ChannelCreateServiceRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/channel/request/ChannelCreateServiceRequest.java @@ -2,7 +2,6 @@ import com.sprint.mission.discodeit.entity.Channel; import com.sprint.mission.discodeit.entity.ChannelType; -import java.util.UUID; import lombok.Builder; import lombok.Getter; @@ -10,17 +9,14 @@ @Builder public class ChannelCreateServiceRequest { - private final String channelName; + private final String name; private final String description; - private final UUID hostId; - private final String channelTypeCode; public Channel toEntity(ChannelType channelType) { return Channel.builder() - .channelName(channelName) + .name(name) .description(description) - .hostId(hostId) - .channelType(channelType) + .type(channelType) .build(); } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/channel/request/ChannelUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/channel/request/ChannelUpdateRequest.java index c41e0c333..98f9120a6 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/channel/request/ChannelUpdateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/channel/request/ChannelUpdateRequest.java @@ -1,22 +1,30 @@ package com.sprint.mission.discodeit.dto.channel.request; +import jakarta.validation.constraints.NotBlank; import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.validator.constraints.Length; @Getter @AllArgsConstructor +@NoArgsConstructor public class ChannelUpdateRequest { - private final String channelName; + @Length(min = 1, max = 100, message = "채널 이름은 1~100자 이어야 함") + @NotBlank(message = "채널 이름은 null이거나 공백이면 안됨") + private String newName; - private final String description; + @Length(min = 1, max = 255, message = "채널 설명은 1~100자 이어야 함") + @NotBlank(message = "채널 설명은 null이거나 공백이면 안됨") + private String newDescription; public ChannelUpdateServiceRequest toServiceRequest(UUID channelId) { return ChannelUpdateServiceRequest.builder() .channelId(channelId) - .channelName(channelName) - .description(description) + .name(newName) + .description(newDescription) .build(); } } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/channel/request/ChannelUpdateServiceRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/channel/request/ChannelUpdateServiceRequest.java index e5c3cfd6d..f83d49380 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/channel/request/ChannelUpdateServiceRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/channel/request/ChannelUpdateServiceRequest.java @@ -9,7 +9,7 @@ public class ChannelUpdateServiceRequest { private final UUID channelId; - private final String channelName; + private final String name; private final String description; } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/channel/request/PrivateChannelCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/channel/request/PrivateChannelCreateRequest.java index 36f3c5f13..219606f03 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/channel/request/PrivateChannelCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/channel/request/PrivateChannelCreateRequest.java @@ -1,25 +1,25 @@ package com.sprint.mission.discodeit.dto.channel.request; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter @AllArgsConstructor +@NoArgsConstructor public class PrivateChannelCreateRequest { - @NotNull(message = "유저은 null이면 안됨") - private final UUID hostId; - - @NotBlank(message = "채널 타입 코드은 null이나 공백이면 안됨") - private final String channelTypeCode; + @Size(min = 2, message = "비밀 채팅방은 최소 2명이 있어야 함") + @NotNull(message = "participantIds는 null이면 안됨") + private List participantIds; public PrivateChannelCreateServiceRequest toServiceRequest() { return PrivateChannelCreateServiceRequest.builder() - .hostId(hostId) - .channelTypeCode(channelTypeCode) + .participantIds(participantIds) .build(); } } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/channel/request/PrivateChannelCreateServiceRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/channel/request/PrivateChannelCreateServiceRequest.java index 3c95e9c9a..16438e2c8 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/channel/request/PrivateChannelCreateServiceRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/channel/request/PrivateChannelCreateServiceRequest.java @@ -3,6 +3,7 @@ import com.sprint.mission.discodeit.entity.Channel; import com.sprint.mission.discodeit.entity.ChannelType; +import java.util.List; import java.util.UUID; import lombok.Builder; import lombok.Getter; @@ -11,13 +12,11 @@ @Builder public class PrivateChannelCreateServiceRequest { - private final UUID hostId; - private final String channelTypeCode; + private final List participantIds; public Channel toEntity(ChannelType channelType) { return Channel.builder() - .hostId(hostId) - .channelType(channelType) + .type(channelType) .build(); } } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/message/MessageResponse.java b/src/main/java/com/sprint/mission/discodeit/dto/message/MessageResponse.java index 692302f1a..aac7f2ce0 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/message/MessageResponse.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/message/MessageResponse.java @@ -1,34 +1,31 @@ package com.sprint.mission.discodeit.dto.message; -import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponse; +import com.sprint.mission.discodeit.dto.user.UserResponse; +import java.time.Instant; +import java.util.List; import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; @Getter +@Builder +@AllArgsConstructor public class MessageResponse { - private final String content; + private final UUID id; - private final UUID channelId; + private Instant createdAt; - private final UUID userId; + private Instant updatedAt; - private final UUID messageId; + private String content; - public MessageResponse(Message message) { - this.messageId = message.getId(); - this.content = message.getContent(); - this.channelId = message.getChannelId(); - this.userId = message.getUserId(); - } + private UUID channelId; + + private UserResponse author; + + private List attachments; - @Override - public String toString() { - return "MessageResponse{" + - "channelId=" + channelId + - ", content='" + content + '\'' + - ", userId=" + userId + - ", messageId=" + messageId + - '}'; - } } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/message/request/MessageCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/message/request/MessageCreateRequest.java index dad53542e..0316e9cd5 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/message/request/MessageCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/message/request/MessageCreateRequest.java @@ -1,33 +1,34 @@ package com.sprint.mission.discodeit.dto.message.request; -import com.sprint.mission.discodeit.entity.BinaryContent; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import java.util.List; import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.web.multipart.MultipartFile; @Getter @AllArgsConstructor +@NoArgsConstructor public class MessageCreateRequest { @NotBlank(message = "메시지는 null이거나 빈문자열, 공백이면 안됨") - private final String message; + private String content; @NotNull(message = "채널은 null이면 안됨") - private final UUID channelId; + private UUID channelId; @NotNull(message = "유저은 null이면 안됨") - private final UUID userId; + private UUID authorId; - private final List binaryContents; - - public MessageCreateServiceRequest toServiceRequest() { + public MessageCreateServiceRequest toServiceRequest(List attachments) { return MessageCreateServiceRequest.builder() - .message(message) + .message(content) + .attachments(attachments) .channelId(channelId) - .userId(userId) + .userId(authorId) .build(); } } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/message/request/MessageCreateServiceRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/message/request/MessageCreateServiceRequest.java index 05662d526..2aff34f47 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/message/request/MessageCreateServiceRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/message/request/MessageCreateServiceRequest.java @@ -1,12 +1,11 @@ package com.sprint.mission.discodeit.dto.message.request; -import com.sprint.mission.discodeit.entity.BinaryContent; -import com.sprint.mission.discodeit.entity.Message; import java.util.ArrayList; import java.util.List; import java.util.UUID; import lombok.Builder; import lombok.Getter; +import org.springframework.web.multipart.MultipartFile; @Getter @Builder @@ -19,14 +18,5 @@ public class MessageCreateServiceRequest { private final UUID userId; @Builder.Default - private final List binaryContents = new ArrayList<>(); - - public Message toEntity() { - return Message.builder() - .content(message) - .channelId(channelId) - .userId(userId) - .binaryContents(binaryContents) - .build(); - } + private final List attachments = new ArrayList<>(); } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/message/request/MessageUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/message/request/MessageUpdateRequest.java index da6058de5..b3b12546d 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/message/request/MessageUpdateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/message/request/MessageUpdateRequest.java @@ -5,24 +5,26 @@ import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter @AllArgsConstructor +@NoArgsConstructor public class MessageUpdateRequest { @NotBlank(message = "메시지는 null이거나 빈문자열, 공백이면 안됨") - private final String content; + private String newContent; @NotNull(message = "채널ID는 null이면 안됨") - private final UUID channelId; + private UUID channelId; @NotNull(message = "유저ID는 null이면 안됨") - private final UUID userId; + private UUID userId; public MessageUpdateServiceRequest toServiceRequest(UUID messageId) { return MessageUpdateServiceRequest.builder() .messageId(messageId) - .content(content) + .content(newContent) .channelId(channelId) .userId(userId) .build(); diff --git a/src/main/java/com/sprint/mission/discodeit/dto/notification/NotificationDto.java b/src/main/java/com/sprint/mission/discodeit/dto/notification/NotificationDto.java new file mode 100644 index 000000000..76b9f56cb --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/dto/notification/NotificationDto.java @@ -0,0 +1,18 @@ +package com.sprint.mission.discodeit.dto.notification; + +import java.time.Instant; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class NotificationDto { + private UUID id; + private UUID receiverId; + private String title; + private String content; + private Instant createdAt; +} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusResponse.java b/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusResponse.java index 948eda918..417a5770a 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusResponse.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusResponse.java @@ -3,30 +3,27 @@ import com.sprint.mission.discodeit.entity.ReadStatus; import java.time.Instant; import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; @Getter +@Builder +@AllArgsConstructor public class ReadStatusResponse { private final UUID id; private final UUID channelId; private final UUID userId; private final Instant lastReadAt; + private Boolean notificationEnabled; public ReadStatusResponse (ReadStatus readStatus) { this.id = readStatus.getId(); this.channelId = readStatus.getChannelId(); this.userId = readStatus.getUserId(); this.lastReadAt = readStatus.getLastReadAt(); + this.notificationEnabled = readStatus.getNotificationEnabled(); } - @Override - public String toString() { - return "ReadStatusResponse{" + - "channelId=" + channelId + - ", id=" + id + - ", userId=" + userId + - ", lastReadAt=" + lastReadAt + - '}'; - } } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/readstatus/request/ReadStatusCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/readstatus/request/ReadStatusCreateRequest.java index 6af602e6e..b07117170 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/readstatus/request/ReadStatusCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/readstatus/request/ReadStatusCreateRequest.java @@ -1,24 +1,31 @@ package com.sprint.mission.discodeit.dto.readstatus.request; import jakarta.validation.constraints.NotNull; +import java.time.Instant; import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter @AllArgsConstructor +@NoArgsConstructor public class ReadStatusCreateRequest { @NotNull(message = "채널Id는 null이면 안됨") - private final UUID channelId; + private UUID channelId; @NotNull(message = "유저Id는 null이면 안됨") - private final UUID userId; + private UUID userId; + + @NotNull(message = "최근 읽은 시간이 null이면 안됨") + private Instant lastReadAt; public ReadStatusCreateServiceRequest toServiceRequest() { return ReadStatusCreateServiceRequest.builder() .channelId(channelId) .userId(userId) + .lastReadAt(lastReadAt) .build(); } } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/readstatus/request/ReadStatusCreateServiceRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/readstatus/request/ReadStatusCreateServiceRequest.java index 737e17284..8d039e28f 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/readstatus/request/ReadStatusCreateServiceRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/readstatus/request/ReadStatusCreateServiceRequest.java @@ -1,6 +1,9 @@ package com.sprint.mission.discodeit.dto.readstatus.request; +import com.sprint.mission.discodeit.entity.Channel; import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.entity.User; +import java.time.Instant; import java.util.UUID; import lombok.Builder; import lombok.Getter; @@ -11,11 +14,12 @@ public class ReadStatusCreateServiceRequest { private final UUID channelId; private final UUID userId; + private final Instant lastReadAt; - public ReadStatus toEntity() { + public ReadStatus toEntity(User user, Channel channel) { return ReadStatus.builder() - .channelId(channelId) - .userId(userId) + .user(user) + .channel(channel) .build(); } } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/readstatus/request/ReadStatusUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/readstatus/request/ReadStatusUpdateRequest.java index 1bf504fe0..3db94b71f 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/readstatus/request/ReadStatusUpdateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/readstatus/request/ReadStatusUpdateRequest.java @@ -1,9 +1,11 @@ package com.sprint.mission.discodeit.dto.readstatus.request; import jakarta.validation.constraints.NotNull; +import java.time.Instant; import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; /** @@ -11,19 +13,20 @@ */ @Getter @AllArgsConstructor +@NoArgsConstructor public class ReadStatusUpdateRequest { - @NotNull(message = "유저 Id는 null이면 안됨") - private final UUID userId; + @NotNull(message = "읽음 상태가 null 이면 안됨") + private Instant newLastReadAt; - @NotNull(message = "채널 Id는 null이면 안됨") - private final UUID channelId; + @NotNull(message = "새 알람은 null이면 안됨") + private Boolean newNotificationEnabled; public ReadStatusUpdateServiceRequest toServiceRequest(UUID readStatusId) { return ReadStatusUpdateServiceRequest.builder() + .newLastReadAt(newLastReadAt) + .newNotificationEnabled(newNotificationEnabled) .readStatusId(readStatusId) - .userId(userId) - .channelId(channelId) .build(); } } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/readstatus/request/ReadStatusUpdateServiceRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/readstatus/request/ReadStatusUpdateServiceRequest.java index 4b0c412c9..e155ecfce 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/readstatus/request/ReadStatusUpdateServiceRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/readstatus/request/ReadStatusUpdateServiceRequest.java @@ -1,5 +1,6 @@ package com.sprint.mission.discodeit.dto.readstatus.request; +import java.time.Instant; import java.util.UUID; import lombok.Builder; import lombok.Getter; @@ -9,6 +10,8 @@ public class ReadStatusUpdateServiceRequest { private final UUID readStatusId; - private final UUID userId; - private final UUID channelId; + + private final Instant newLastReadAt; + + private Boolean newNotificationEnabled; } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/user/UserResponse.java b/src/main/java/com/sprint/mission/discodeit/dto/user/UserResponse.java index fc21ba054..5116f28c6 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/user/UserResponse.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/user/UserResponse.java @@ -1,73 +1,40 @@ package com.sprint.mission.discodeit.dto.user; -import com.sprint.mission.discodeit.dto.message.MessageResponse; -import com.sprint.mission.discodeit.entity.ActiveStatus; -import com.sprint.mission.discodeit.entity.BaseEntity; -import com.sprint.mission.discodeit.entity.Message; -import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponse; +import com.sprint.mission.discodeit.entity.Role; import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.entity.UserStatus; -import java.time.Instant; -import java.util.List; -import java.util.Set; import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter +@NoArgsConstructor +@Builder +@AllArgsConstructor public class UserResponse { - private final List messages; - private final List channels; - private final UUID id; - private final Instant createdAt; - private final Instant updatedAt; - private final String userName; - private final String email; - private final String phoneNumber; - private final ActiveStatus activeStatus; - private final UserStatus userStatus; - private final UUID profileId; + private UUID id; + private String username; + private String email; + private BinaryContentResponse profile; + private boolean online; + private Role role; - public UserResponse(User user, UserStatus userStatus) { + public UserResponse(User user) { this.id = user.getId(); - this.createdAt = user.getCreatedAt(); - this.updatedAt = user.getUpdatedAt(); - this.messages = toMessageResponses(user.getMessages()); - this.channels = toChannelIds(user.getReadStatuses()); - this.userName = user.getUserName(); + this.username = user.getUsername(); this.email = user.getEmail(); - this.phoneNumber = user.getPhoneNumber(); - this.activeStatus = user.getActiveStatus(); - this.userStatus = userStatus; - this.profileId = user.getOptionalProfile() - .map(BaseEntity::getId) - .orElse(null); + assignBinaryContentResponseIfUserProfilePresent(user); + this.role = user.getRole(); } - private static List toMessageResponses(Set messages) { - return messages.stream().map(MessageResponse::new).toList(); + private void assignBinaryContentResponseIfUserProfilePresent(User user) { + user.getOptionalProfile().ifPresent(binaryContent -> this.profile = new BinaryContentResponse(binaryContent)); } - private static List toChannelIds(Set readStatuses) { - return readStatuses.stream() - .map(ReadStatus::getChannelId) - .map(UUID::toString) - .toList(); - } - - @Override - public String toString() { - return "UserResponse{" + - "activeStatus=" + activeStatus + - ", messages=" + messages + - ", channels=" + channels + - ", id=" + id + - ", createdAt=" + createdAt + - ", updatedAt=" + updatedAt + - ", userName='" + userName + '\'' + - ", email='" + email + '\'' + - ", phoneNumber='" + phoneNumber + '\'' + - ", userStatus=" + userStatus + - '}'; + public void updateOnline(boolean online) { + this.online = online; } } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/user/request/UserCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/user/request/UserCreateRequest.java index f840b1089..a0bc6ae60 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/user/request/UserCreateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/user/request/UserCreateRequest.java @@ -2,37 +2,38 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; +import org.hibernate.validator.constraints.Length; import org.springframework.web.multipart.MultipartFile; @Getter @Setter +@NoArgsConstructor @AllArgsConstructor public class UserCreateRequest { + @Length(min = 1, max = 50, message = "유저 네임은 1 ~ 50자 이어야함") @NotBlank(message = "유저 네임은 null이거나 공백이면 안됨") - private String userName; + private String username; + @Length(min = 1, max = 100, message = "이메일은 1 ~ 100자 이어야함") @NotBlank(message = "이메일은 필수입니다.") @Email(message = "이메일 형식이 올바르지 않음") private String email; - @Pattern(regexp = "^01([0|1|6|7|8|9])-?([0-9]{3,4})-?([0-9]{4})$", message = "휴대폰 번호 형식이 올바르지 않음.") - private String phoneNumber; - + @Length(min = 1, max = 60, message = "비밀번호는 1 ~ 60자 이어야함") @NotBlank(message = "비밀번호는 null이거나 공백이면 안됨") private String password; private MultipartFile profile; - public UserCreateServiceRequest toServiceRequest() { + public UserCreateServiceRequest toServiceRequest(MultipartFile profile) { return UserCreateServiceRequest.builder() - .userName(userName) + .username(username) .email(email) - .phoneNumber(phoneNumber) .password(password) .profile(profile) .build(); diff --git a/src/main/java/com/sprint/mission/discodeit/dto/user/request/UserCreateServiceRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/user/request/UserCreateServiceRequest.java index b5baa50bc..97375c5db 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/user/request/UserCreateServiceRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/user/request/UserCreateServiceRequest.java @@ -1,6 +1,5 @@ package com.sprint.mission.discodeit.dto.user.request; -import com.sprint.mission.discodeit.entity.User; import lombok.Builder; import lombok.Getter; import org.springframework.web.multipart.MultipartFile; @@ -9,18 +8,8 @@ @Builder public class UserCreateServiceRequest { - private final String userName; + private final String username; private final String email; - private final String phoneNumber; private final String password; private final MultipartFile profile; - - public User toEntity() { - return User.builder() - .userName(userName) - .email(email) - .phoneNumber(phoneNumber) - .password(password) - .build(); - } } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/user/request/UserUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/user/request/UserUpdateRequest.java index 1d9e1677d..ec91b4d4a 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/user/request/UserUpdateRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/user/request/UserUpdateRequest.java @@ -1,59 +1,36 @@ package com.sprint.mission.discodeit.dto.user.request; -import com.sprint.mission.discodeit.entity.BinaryContent; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import org.springframework.web.multipart.MultipartFile; @Getter @Setter @AllArgsConstructor +@NoArgsConstructor public class UserUpdateRequest { - private String userName; + @NotBlank(message = "유저 네임은 null이거나 공백이면 안됨") + private String newUsername; - @NotBlank(message = "이메일은 필수입니다.") @Email(message = "이메일 형식이 올바르지 않음") - private String email; + private String newEmail; - @Pattern(regexp = "^01([0|1|6|7|8|9])-?([0-9]{3,4})-?([0-9]{4})$", message = "휴대폰 번호 형식이 올바르지 않음.") - private String phoneNumber; + //선택적으로 들어오는 필드라 null 허용해야함 + private String newPassword; - private String password; - - private MultipartFile profile; - - public UserUpdateServiceRequest toServiceRequest(UUID userId) { + public UserUpdateServiceRequest toServiceRequest(UUID userId, MultipartFile profile) { return UserUpdateServiceRequest.builder() .userId(userId) - .userName(userName) - .email(email) - .phoneNumber(phoneNumber) - .password(password) + .newUsername(newUsername) + .newEmail(newEmail) + .newPassword(newPassword) .profile(profile) .build(); } - - public void validate(String userName, String email, String phoneNumber, String password, BinaryContent profile) { - if (userName == null || userName.trim().isEmpty()) { - throw new IllegalArgumentException("유저 네임은 null이거나 공백이면 안됨"); - } - if (email == null || email.trim().isEmpty() || !email.matches("^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,6}$")) { - throw new IllegalArgumentException("이메일 형식에 맞지 않음"); - } - if (phoneNumber == null || phoneNumber.trim().isEmpty() || !phoneNumber.matches("^[0-9+\\-]+$")) { - throw new IllegalArgumentException("전화번호 형식에 맞지 않음"); - } - if (profile != null && !profile.getFileType().getCode().startsWith("1")) { - throw new IllegalArgumentException("이미지 외의 파일은 프로필 등록 불가"); - } - if (password == null || password.trim().isEmpty()) { - throw new IllegalArgumentException("비밀번호는 null이거나 공백이면 안됨"); - } - } } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/user/request/UserUpdateServiceRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/user/request/UserUpdateServiceRequest.java index ee0bb2a3d..3000c3ae9 100644 --- a/src/main/java/com/sprint/mission/discodeit/dto/user/request/UserUpdateServiceRequest.java +++ b/src/main/java/com/sprint/mission/discodeit/dto/user/request/UserUpdateServiceRequest.java @@ -11,9 +11,8 @@ public class UserUpdateServiceRequest { private final UUID userId; - private final String userName; - private final String email; - private final String phoneNumber; - private final String password; + private final String newUsername; + private final String newEmail; + private final String newPassword; private final MultipartFile profile; } diff --git a/src/main/java/com/sprint/mission/discodeit/dto/userstatus/UserStatusResponse.java b/src/main/java/com/sprint/mission/discodeit/dto/userstatus/UserStatusResponse.java deleted file mode 100644 index 779220bb6..000000000 --- a/src/main/java/com/sprint/mission/discodeit/dto/userstatus/UserStatusResponse.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.sprint.mission.discodeit.dto.userstatus; - -import com.sprint.mission.discodeit.entity.UserStatus; -import java.time.Instant; -import java.util.UUID; -import lombok.Getter; - -@Getter -public class UserStatusResponse { - - private final UUID userId; - - private final Instant lastOnlineTime; - - private final boolean isOnline; - - public UserStatusResponse(UserStatus userStatus) { - this.userId = userStatus.getUserId(); - this.lastOnlineTime = userStatus.getLastOnlineTime(); - this.isOnline = userStatus.isOnline(); - } - - @Override - public String toString() { - return "UserStatusResponse{" + - "isOnline=" + isOnline + - ", userId=" + userId + - ", lastOnlineTime=" + lastOnlineTime + - '}'; - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/userstatus/request/UserStatusCreateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/userstatus/request/UserStatusCreateRequest.java deleted file mode 100644 index f5b1e2784..000000000 --- a/src/main/java/com/sprint/mission/discodeit/dto/userstatus/request/UserStatusCreateRequest.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.sprint.mission.discodeit.dto.userstatus.request; - -import java.util.UUID; -import lombok.Getter; - -@Getter -public class UserStatusCreateRequest { - - private final UUID userId; - - public UserStatusCreateRequest(UUID userId) { - validate(userId); - this.userId = userId; - } - - public void validate(UUID userId) { - if (userId == null) { - throw new IllegalArgumentException("userId는 null이면 안됨"); - } - } - - public UserStatusCreateServiceRequest toServiceRequest() { - return UserStatusCreateServiceRequest.builder() - .userId(userId) - .build(); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/userstatus/request/UserStatusCreateServiceRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/userstatus/request/UserStatusCreateServiceRequest.java deleted file mode 100644 index c2af1e81c..000000000 --- a/src/main/java/com/sprint/mission/discodeit/dto/userstatus/request/UserStatusCreateServiceRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.sprint.mission.discodeit.dto.userstatus.request; - -import com.sprint.mission.discodeit.entity.UserStatus; -import java.util.UUID; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class UserStatusCreateServiceRequest { - - private final UUID userId; - - public UserStatus toEntity() { - return UserStatus.builder() - .userId(userId) - .build(); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/userstatus/request/UserStatusUpdateRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/userstatus/request/UserStatusUpdateRequest.java deleted file mode 100644 index ae6fe255f..000000000 --- a/src/main/java/com/sprint/mission/discodeit/dto/userstatus/request/UserStatusUpdateRequest.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.sprint.mission.discodeit.dto.userstatus.request; - -import java.util.UUID; -import lombok.Getter; - -@Getter -public class UserStatusUpdateRequest { - - private final UUID userId; - - public UserStatusUpdateRequest(UUID userId) { - validate(userId); - this.userId = userId; - } - - public void validate(UUID userId) { - if (userId == null) { - throw new IllegalArgumentException("userId는 null이면 안됨"); - } - } - - public UserStatusUpdateServiceRequest toServiceRequest() { - return UserStatusUpdateServiceRequest.builder() - .userId(userId) - .build(); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/dto/userstatus/request/UserStatusUpdateServiceRequest.java b/src/main/java/com/sprint/mission/discodeit/dto/userstatus/request/UserStatusUpdateServiceRequest.java deleted file mode 100644 index 31103deef..000000000 --- a/src/main/java/com/sprint/mission/discodeit/dto/userstatus/request/UserStatusUpdateServiceRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sprint.mission.discodeit.dto.userstatus.request; - -import java.util.UUID; -import lombok.Builder; -import lombok.Getter; - - -@Getter -@Builder -public class UserStatusUpdateServiceRequest { - - private final UUID userId; - -} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/ActiveStatus.java b/src/main/java/com/sprint/mission/discodeit/entity/ActiveStatus.java deleted file mode 100644 index 2ee856dc7..000000000 --- a/src/main/java/com/sprint/mission/discodeit/entity/ActiveStatus.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.sprint.mission.discodeit.entity; - -import java.util.Arrays; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public enum ActiveStatus { - ACTIVE("STATUS-001", "활성화 회원입니다"), - DORMANT("STATUS-002", "휴면 회원입니다"), - DELETED("STATUS-003", "탈퇴한 회원입니다"); - - private final String statusCode; - private final String description; - - - public String getStatusCode() { - return this.statusCode; - } - - public String getDescriptionByCode(String statusCode) { - return Arrays.stream(ActiveStatus.values()) - .filter(status -> status.getStatusCode().equals(statusCode)) - .findFirst() - .map(ActiveStatus::getDescription) - .orElse(null); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/BaseEntity.java b/src/main/java/com/sprint/mission/discodeit/entity/BaseEntity.java index 22b3e01f6..917062a96 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/BaseEntity.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/BaseEntity.java @@ -1,26 +1,27 @@ package com.sprint.mission.discodeit.entity; -import java.io.Serializable; +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; import java.time.Instant; import java.util.UUID; import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; @Getter -public abstract class BaseEntity implements Serializable { +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { - protected static final long serialVersionUID = 1L; + @Id + @GeneratedValue(strategy = GenerationType.UUID) + protected UUID id; - protected final UUID id; - protected final Instant createdAt; - protected Instant updatedAt; - - public BaseEntity() { - this.id = UUID.randomUUID(); - this.createdAt = Instant.now(); - this.updatedAt = Instant.now(); - } - - public void setUpdatedAt() { - this.updatedAt = Instant.now(); - } + @CreatedDate + @Column(name = "created_at", updatable = false) + protected Instant createdAt; } diff --git a/src/main/java/com/sprint/mission/discodeit/entity/BaseUpdatableEntity.java b/src/main/java/com/sprint/mission/discodeit/entity/BaseUpdatableEntity.java new file mode 100644 index 000000000..bdcdd7ff6 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/BaseUpdatableEntity.java @@ -0,0 +1,20 @@ +package com.sprint.mission.discodeit.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.Instant; +import lombok.Getter; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseUpdatableEntity extends BaseEntity { + + @Column(name = "updated_at") + @LastModifiedDate + protected Instant updatedAt; +} + diff --git a/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java index 8e1501894..5638af3fa 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContent.java @@ -2,35 +2,52 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import java.time.Instant; -import java.util.UUID; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter -public class BinaryContent extends BaseEntity { +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "binary_contents") +public class BinaryContent extends BaseUpdatableEntity { - private final UUID userId; - private final UUID messageId; - private final byte[] data; + @Column(name = "bytes", nullable = false) + private byte[] bytes; - private final Instant createdAt; + @Column(name = "content_type", nullable = false) + private String contentType; + @Column(name = "size", nullable = false) + private Long size; + + @Column(name = "file_name", nullable = false) private String fileName; - private FileType fileType; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private BinaryContentStatus status = BinaryContentStatus.PROCESSING; @Builder @JsonCreator - public BinaryContent(@JsonProperty("userId") UUID userId, - @JsonProperty("messageId") UUID messageId, + public BinaryContent( @JsonProperty("fileName") String fileName, - @JsonProperty("fileType") FileType fileType, - @JsonProperty("data") byte[] data) { - this.userId = userId; - this.messageId = messageId; - this.data = data; - this.createdAt = Instant.now(); + @JsonProperty("contentType") String contentType, + @JsonProperty("size") Long size, + @JsonProperty("data") byte[] bytes) { + this.bytes = bytes; + this.contentType = contentType; + this.size = size; this.fileName = fileName; - this.fileType = fileType; + } + + public void updateStatus(BinaryContentStatus binaryContentStatus) { + this.status = binaryContentStatus; } } diff --git a/src/main/java/com/sprint/mission/discodeit/entity/BinaryContentStatus.java b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContentStatus.java new file mode 100644 index 000000000..e9fc6f236 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/BinaryContentStatus.java @@ -0,0 +1,8 @@ +package com.sprint.mission.discodeit.entity; + +public enum BinaryContentStatus { + + PROCESSING, + SUCCESS, + FAIL +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/Channel.java b/src/main/java/com/sprint/mission/discodeit/entity/Channel.java index 73a7db267..0e72fc331 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/Channel.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/Channel.java @@ -1,83 +1,63 @@ package com.sprint.mission.discodeit.entity; -import com.sprint.mission.discodeit.util.DataExistenceChecker; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; -import java.util.UUID; +import com.sprint.mission.discodeit.constant.ChannelErrorCode; +import com.sprint.mission.discodeit.exception.ChannelException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter -public class Channel extends BaseEntity { +@Entity +@Table(name = "channels") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Channel extends BaseUpdatableEntity { - private final Set messages; - private final Set readStatuses; - private final UUID hostId; + @Column(name = "name", nullable = true) + private String name; - private String channelName; + @Column(name = "description", nullable = true) private String description; - private ChannelType channelType; + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private ChannelType type; @Builder - private Channel(UUID hostId, String channelName, String description, ChannelType channelType) { - this.hostId = hostId; - this.channelName = channelName; - this.description = description; - this.readStatuses= new LinkedHashSet<>(); - this.messages = new LinkedHashSet<>(); - this.channelType = channelType; - } - - public void addUserReadStatus(ReadStatus readStatus) { - if (!DataExistenceChecker.isExistDataInField(readStatuses, readStatus)) { - readStatuses.add(readStatus); - } - } - - public void removeUserReadStatus(ReadStatus readStatus) { - readStatuses.remove(readStatus); - } - - public void addMessage(Message message) { - if (!DataExistenceChecker.isExistDataInField(messages, message)) { - messages.add(message); + private Channel(String name, String description, ChannelType type) { + if(type == ChannelType.PUBLIC) { + this.name = name; + this.description = description; + this.type = type; + } else { + validatePrivateChannelDoesNotHaveNameOrDescription(name, description); + this.type = type; } } - public void removeMessage(Message message) { - if(DataExistenceChecker.isExistDataInField(messages, message)) { - messages.remove(message); - } - } - - public List getMessageContents() { - return messages.stream().map(Message::getContent).toList(); - } - - private List getUserIds() { - return readStatuses.stream().map(ReadStatus::getUserId).map(UUID::toString).toList(); - } - public void editChannelName(String channelName) { - this.channelName = channelName; + this.name = channelName; } public void editDescription(String description) { this.description = description; } - @Override - public String toString() { - return "Channel{" + - "id=" + id + - ", createdAt=" + createdAt + - ", updatedAt=" + updatedAt + - ", channelName='" + channelName + '\'' + - ", description='" + description + '\'' + - ", messages=" + this.getMessageContents() + - ", users=" + this.getUserIds() + - '}'; + + private void validatePrivateChannelDoesNotHaveNameOrDescription(String name, String description) { + if(name != null && description != null) { + throw new ChannelException(ChannelErrorCode.PRIVATE_CHANNEL_DOES_NOT_HAVE_NAME_AND_DESCRIPTION); + } + if(name != null) { + throw new ChannelException(ChannelErrorCode.PRIVATE_CHANNEL_DOES_NOT_HAVE_NAME); + } + if(description!=null) { + throw new ChannelException(ChannelErrorCode.PRIVATE_CHANNEL_DOES_NOT_HAVE_DESCRIPTION); + } } } diff --git a/src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java b/src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java index e2eed7aa1..21523a47c 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/ChannelType.java @@ -1,34 +1,10 @@ package com.sprint.mission.discodeit.entity; -import java.util.Arrays; import lombok.Getter; import lombok.RequiredArgsConstructor; @Getter @RequiredArgsConstructor public enum ChannelType { - - PUBLIC_CHATTING("CHANNEL-100", "공공 채팅방"), - PUBLIC_VOICE("CHANNEL-101", "공공 음성채팅방"), - - PRIVATE_CHATTING("CHANNEL-200", "비밀 채팅방"), - PRIVATE_VOICE("CHANNEL-201", "비밀 음성채팅방"); - - private final String code; - private final String description; - - public static ChannelType getChannelTypeByCode(String code) { - return Arrays.stream(ChannelType.values()) - .filter(channelType -> channelType.getCode().equals(code)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("ChannelType not found.")); - } - - public String getDescriptionByCode(String code) { - return Arrays.stream(ChannelType.values()) - .filter(channelType -> channelType.getCode().equals(code)) - .findFirst() - .map(ChannelType::getDescription) - .orElseThrow(() -> new IllegalArgumentException("ChannelType not found.")); - } + PRIVATE, PUBLIC } diff --git a/src/main/java/com/sprint/mission/discodeit/entity/Message.java b/src/main/java/com/sprint/mission/discodeit/entity/Message.java index 5716bd704..54c42ebba 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/Message.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/Message.java @@ -1,50 +1,73 @@ package com.sprint.mission.discodeit.entity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.ArrayList; import java.util.List; import java.util.UUID; +import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter -public class Message extends BaseEntity{ +@Entity +@Table(name = "messages") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Message extends BaseUpdatableEntity { + @Column(name = "content", nullable = false) private String content; - private final UUID channelId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "channel_id", nullable = false) + private Channel channel; - private final UUID userId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id", nullable = false) + private User author; - private final List binaryContents; + @OneToMany(mappedBy = "message", cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, fetch = FetchType.LAZY, orphanRemoval = true) + private List attachments = new ArrayList<>(); @Builder - public Message(String content, UUID channelId, UUID userId, List binaryContents) { + public Message(String content, Channel channel, User author) { this.content = content; - this.channelId = channelId; - this.userId = userId; - this.binaryContents = binaryContents; + this.channel = channel; + this.author = author; + } + + public UUID getAuthorId() { + return author.getId(); + } + + public UUID getChannelId() { + return channel.getId(); } public void editContent(String content) { this.content = content; } - public void addBinaryContent(BinaryContent binaryContent) { - this.binaryContents.add(binaryContent); + public void addAttachments(List attachments) { + this.attachments.addAll(attachments); + } + + public void removeAttachments(List attachments) { + this.attachments.removeAll(attachments); } - public void removeBinaryContent(BinaryContent binaryContent) { - this.binaryContents.remove(binaryContent); + public void addAttachment(MessageAttachment attachment) { + this.attachments.add(attachment); } - @Override - public String toString() { - return "Message{" + - "id=" + id + - ", createdAt=" + createdAt + - ", updatedAt=" + updatedAt + - ", content='" + content + '\'' + - ", userId=" + userId + - ", channelId=" + channelId + - '}'; + public void removeAttachment(MessageAttachment attachment) { + this.attachments.remove(attachment); } } diff --git a/src/main/java/com/sprint/mission/discodeit/entity/MessageAttachment.java b/src/main/java/com/sprint/mission/discodeit/entity/MessageAttachment.java new file mode 100644 index 000000000..4334d2825 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/MessageAttachment.java @@ -0,0 +1,30 @@ +package com.sprint.mission.discodeit.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "message_attachments") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class MessageAttachment extends BaseEntity{ + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "message_id") + private Message message; + + @OneToOne + @JoinColumn(name = "attachment_id") + private BinaryContent binaryContent; +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/Notification.java b/src/main/java/com/sprint/mission/discodeit/entity/Notification.java new file mode 100644 index 000000000..da74b8792 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/Notification.java @@ -0,0 +1,37 @@ +package com.sprint.mission.discodeit.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Builder +@AllArgsConstructor +@Table(name = "notification") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notification extends BaseUpdatableEntity{ + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id") + private User receiver; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "content", nullable = false) + private String content; + + public UUID getReceiverId() { + return receiver.getId(); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java b/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java index 0a27be628..5a44f76fe 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/ReadStatus.java @@ -1,25 +1,63 @@ package com.sprint.mission.discodeit.entity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import java.time.Instant; import java.util.UUID; +import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter -public class ReadStatus extends BaseEntity { +@Entity +@Table(name = "read_statuses", +uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "channel_id"}) +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReadStatus extends BaseUpdatableEntity { - private final UUID userId; - private final UUID channelId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "channel_id", nullable = false) + private Channel channel; + + @Column(name = "last_read_at", nullable = false) private Instant lastReadAt; + @Column(name = "notificationEnabled", nullable = false) + private Boolean notificationEnabled; + @Builder - public ReadStatus(UUID userId, UUID channelId) { - this.userId = userId; - this.channelId = channelId; + public ReadStatus(User user, Channel channel) { + this.user = user; + this.channel = channel; this.lastReadAt = Instant.now(); + this.notificationEnabled = channel.getType() == ChannelType.PRIVATE; } public void updateLastReadAt() { - this.setUpdatedAt(); + this.lastReadAt = Instant.now(); + } + + public void updateNotificationEnabled(boolean notificationEnabled) { + this.notificationEnabled = notificationEnabled; + } + + public UUID getChannelId() { + return this.channel.getId(); + } + + public UUID getUserId() { + return this.user.getId(); } } diff --git a/src/main/java/com/sprint/mission/discodeit/entity/Role.java b/src/main/java/com/sprint/mission/discodeit/entity/Role.java new file mode 100644 index 000000000..b38074ccd --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/entity/Role.java @@ -0,0 +1,7 @@ +package com.sprint.mission.discodeit.entity; + +public enum Role { + ADMIN, + USER, + CHANNEL_MANAGER; +} diff --git a/src/main/java/com/sprint/mission/discodeit/entity/User.java b/src/main/java/com/sprint/mission/discodeit/entity/User.java index 848d98611..c2e1f36bd 100644 --- a/src/main/java/com/sprint/mission/discodeit/entity/User.java +++ b/src/main/java/com/sprint/mission/discodeit/entity/User.java @@ -1,83 +1,64 @@ package com.sprint.mission.discodeit.entity; -import com.sprint.mission.discodeit.util.DataExistenceChecker; -import java.util.LinkedHashSet; -import java.util.List; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; import java.util.Optional; -import java.util.Set; -import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; +@Entity +@Table(name = "users") @Getter -public class User extends BaseEntity { +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseUpdatableEntity { - private final Set messages; - private final Set readStatuses; + @Column(name = "username", nullable = false, unique = true) + private String username; - private String userName; + @Column(name = "email", nullable = false, unique = true) private String email; - private String phoneNumber; + + @Column(name = "password", nullable = false) private String password; - private ActiveStatus activeStatus; + + @OneToOne(cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true, fetch = FetchType.LAZY) + @JoinColumn(name = "profile_id", nullable = true) private BinaryContent profile; - @Builder - public User(String userName, String email, String phoneNumber, String password, BinaryContent profile) { - this.userName = userName; + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false) + private Role role; + + + public User(String username, String email, String password, BinaryContent profile) { + this.username = username; this.email = email; - this.phoneNumber = phoneNumber; this.password = password; this.profile = profile; - this.messages = new LinkedHashSet<>(); - this.readStatuses = new LinkedHashSet<>(); - this.activeStatus = ActiveStatus.ACTIVE; } public Optional getOptionalProfile() { return Optional.ofNullable(profile); } - public List getChannelIds() { - return readStatuses.stream().map(ReadStatus::getChannelId).toList(); - } - - public List getMessageContents() { - return messages.stream().map(Message::getContent).toList(); - } - - public void addReadStatus(ReadStatus readStatus) { - if(!DataExistenceChecker.isExistDataInField(readStatuses, readStatus)) { - readStatuses.add(readStatus); - } - } - - public void removeReadStatus(ReadStatus readStatus) { - readStatuses.remove(readStatus); - } - - public void addMessage(Message message) { - if(!DataExistenceChecker.isExistDataInField(messages, message)) { - messages.add(message); - } - } - - public void removeMessage(Message message) { - if(DataExistenceChecker.isExistDataInField(messages, message)) { - messages.remove(message); - } - } - public void updateEmail(String email) { this.email = email; } - public void updatePhoneNumber(String phoneNumber) { - this.phoneNumber = phoneNumber; - } - - public void updateUserName(String userName) { - this.userName = userName; + public void updateUserName(String username) { + this.username = username; } public void updatePassword(String password) { @@ -88,30 +69,7 @@ public void updateProfile(BinaryContent profile) { this.profile = profile; } - public void updateActiveStatus(ActiveStatus activeStatus) { - this.activeStatus = activeStatus; - } - - public void updateUserStatus() { - this.activeStatus = this.activeStatus == ActiveStatus.ACTIVE ? ActiveStatus.DORMANT : ActiveStatus.ACTIVE; - } - - public void editMemberStatus(ActiveStatus activeStatus) { - this.activeStatus = activeStatus; - } - - - @Override - public String toString() { - return "User{" + - "id=" + id + - ", createdAt=" + createdAt + - ", updatedAt=" + updatedAt + - ", userName='" + userName + '\'' + - ", phoneNumber='" + phoneNumber + '\'' + - ", email='" + email + '\'' + - ", messages=" + this.getMessages() + - ", channels=" + this.getChannelIds() + - '}'; + public void updateRole(Role role) { + this.role = role; } } diff --git a/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java b/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java deleted file mode 100644 index 05576625c..000000000 --- a/src/main/java/com/sprint/mission/discodeit/entity/UserStatus.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.sprint.mission.discodeit.entity; - -import java.time.Duration; -import java.time.Instant; -import java.util.UUID; -import lombok.Builder; -import lombok.Getter; - -@Getter -public class UserStatus extends BaseEntity { - - private final UUID userId; - - private Instant lastOnlineTime = Instant.now();; - - @Builder - public UserStatus(UUID userId) { - this.userId = userId; - } - - public boolean isOnline() { - return Duration.between(lastOnlineTime, Instant.now()).toMinutes() <= 5; - } - - public void updateLastOnlineTime() { - this.lastOnlineTime = Instant.now(); - this.setUpdatedAt(); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/event/BinaryContentCreatedEvent.java b/src/main/java/com/sprint/mission/discodeit/event/BinaryContentCreatedEvent.java new file mode 100644 index 000000000..5c930cd75 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/BinaryContentCreatedEvent.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.event; + +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class BinaryContentCreatedEvent { + private UUID binaryContentId; + private byte[] bytes; +} diff --git a/src/main/java/com/sprint/mission/discodeit/event/BinaryContentUploadFailureEvent.java b/src/main/java/com/sprint/mission/discodeit/event/BinaryContentUploadFailureEvent.java new file mode 100644 index 000000000..7bb37ea40 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/BinaryContentUploadFailureEvent.java @@ -0,0 +1,15 @@ +package com.sprint.mission.discodeit.event; + +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class BinaryContentUploadFailureEvent { + private String requestId; + private UUID binaryContentId; + private String reason; +} diff --git a/src/main/java/com/sprint/mission/discodeit/event/MessageCreatedEvent.java b/src/main/java/com/sprint/mission/discodeit/event/MessageCreatedEvent.java new file mode 100644 index 000000000..dbcd3355a --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/MessageCreatedEvent.java @@ -0,0 +1,16 @@ +package com.sprint.mission.discodeit.event; + +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class MessageCreatedEvent { + private UUID id; + private UUID authorId; + private UUID channelId; + private String content; +} diff --git a/src/main/java/com/sprint/mission/discodeit/event/RoleUpdatedEvent.java b/src/main/java/com/sprint/mission/discodeit/event/RoleUpdatedEvent.java new file mode 100644 index 000000000..23f043c14 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/RoleUpdatedEvent.java @@ -0,0 +1,16 @@ +package com.sprint.mission.discodeit.event; + +import com.sprint.mission.discodeit.entity.Role; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class RoleUpdatedEvent { + private UUID changedUserId; + private Role oldRole; + private Role newRole; +} diff --git a/src/main/java/com/sprint/mission/discodeit/event/kafka/KafkaProduceRequiredEventListener.java b/src/main/java/com/sprint/mission/discodeit/event/kafka/KafkaProduceRequiredEventListener.java new file mode 100644 index 000000000..95974202b --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/kafka/KafkaProduceRequiredEventListener.java @@ -0,0 +1,53 @@ +package com.sprint.mission.discodeit.event.kafka; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.event.BinaryContentUploadFailureEvent; +import com.sprint.mission.discodeit.event.MessageCreatedEvent; +import com.sprint.mission.discodeit.event.RoleUpdatedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@RequiredArgsConstructor +@Component +public class KafkaProduceRequiredEventListener { + + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + @Async("eventTaskExecutor") + @TransactionalEventListener + public void on(MessageCreatedEvent event) { + sendToKafka(event, String.format("discodeit.%s", event.getClass().getSimpleName())); + } + + @Async("eventTaskExecutor") + @TransactionalEventListener + public void on(RoleUpdatedEvent event) { + sendToKafka(event, String.format("discodeit.%s", event.getClass().getSimpleName())); + } + + @Async("eventTaskExecutor") + @EventListener + public void on(BinaryContentUploadFailureEvent event) { + sendToKafka(event, String.format("discodeit.%s", event.getClass().getSimpleName())); + } + + + private void sendToKafka(T event, String topic) { + try { + String payload = objectMapper.writeValueAsString(event); + kafkaTemplate.send(topic, payload); + log.debug("Published {} event {} to Kafka", topic, payload); + } catch (JsonProcessingException e) { + log.error("Failed to send event to Kafka", e); + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/event/kafka/NotificationRequiredTopicListener.java b/src/main/java/com/sprint/mission/discodeit/event/kafka/NotificationRequiredTopicListener.java new file mode 100644 index 000000000..64d3bc37d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/kafka/NotificationRequiredTopicListener.java @@ -0,0 +1,100 @@ +package com.sprint.mission.discodeit.event.kafka; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.constant.ChannelErrorCode; +import com.sprint.mission.discodeit.constant.UserErrorCode; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.event.BinaryContentUploadFailureEvent; +import com.sprint.mission.discodeit.event.MessageCreatedEvent; +import com.sprint.mission.discodeit.event.RoleUpdatedEvent; +import com.sprint.mission.discodeit.exception.ChannelException; +import com.sprint.mission.discodeit.exception.UserException; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.NotificationService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class NotificationRequiredTopicListener { + + private final UserRepository userRepository; + private final ChannelRepository channelRepository; + private final ReadStatusRepository readStatusRepository; + private final NotificationService notificationService; + private final ObjectMapper objectMapper; + + @KafkaListener(topics = "discodeit.MessageCreatedEvent") + public void onMessageCreatedEvent(String kafkaEvent) { + try { + MessageCreatedEvent event = objectMapper.readValue(kafkaEvent, + MessageCreatedEvent.class); + List readStatuses = readStatusRepository.findReadStatusByChannelId(event.getChannelId()); + User author = userRepository.findById(event.getAuthorId()) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + Channel channel = channelRepository.findById(event.getChannelId()) + .orElseThrow(() -> new ChannelException(ChannelErrorCode.CHANNEL_NOT_FOUND)); + + String channelName = getMessageSource(channel, author); + String title = String.format("%s (#%s)", author.getUsername(), channelName); + + readStatuses.stream() + .filter(readStatus -> !readStatus.getUserId().equals(event.getAuthorId())) + .forEach(readStatus -> + notificationService.create(readStatus.getUser(),title,event.getContent())); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + private static String getMessageSource(Channel channel, User author) { + return channel.getType() == ChannelType.PRIVATE ? author.getUsername() : channel.getName(); + } + + @KafkaListener(topics = "discodeit.RoleUpdatedEvent") + public void onRoleUpdatedEvent(String kafkaEvent) { + try { + RoleUpdatedEvent event = objectMapper.readValue(kafkaEvent, + RoleUpdatedEvent.class); + User changedUser = userRepository.findById(event.getChangedUserId()) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + String title = "권한이 변경되었습니다."; + String content = String.format("%s -> %s", event.getOldRole().name(), event.getNewRole().name()); + notificationService.create(changedUser, title, content); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + @KafkaListener(topics = "discodeit.BinaryContentUploadFailureEvent") + public void onS3UploadFailedEvent(String kafkaEvent) { + try { + BinaryContentUploadFailureEvent event = objectMapper.readValue(kafkaEvent, + BinaryContentUploadFailureEvent.class); + userRepository.findAllByRole(Role.ADMIN) + .forEach( + admin -> + { + String Content = String.format(""" + RequestId: %s + BinaryContentId: %s + Error: %s""",event.getRequestId(),event.getBinaryContentId(),event.getReason()); + notificationService.create(admin, "Binary Content 업로드 실패", Content); + } + ); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/event/listener/BinaryContentEventListener.java b/src/main/java/com/sprint/mission/discodeit/event/listener/BinaryContentEventListener.java new file mode 100644 index 000000000..b2524e039 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/listener/BinaryContentEventListener.java @@ -0,0 +1,42 @@ +package com.sprint.mission.discodeit.event.listener; + +import com.sprint.mission.discodeit.entity.BinaryContentStatus; +import com.sprint.mission.discodeit.event.BinaryContentCreatedEvent; +import com.sprint.mission.discodeit.service.BinaryContentService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BinaryContentEventListener { + + private final BinaryContentStorage binaryContentStorage; + private final BinaryContentService binaryContentService; + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onBinaryContentCreatedEvent(BinaryContentCreatedEvent event) { + UUID binaryContentId = event.getBinaryContentId(); + byte[] bytes = event.getBytes(); + + try { + binaryContentStorage.put(binaryContentId, bytes); + binaryContentService.updateBinaryContentStatus(binaryContentId, BinaryContentStatus.SUCCESS); + log.debug("[BinaryContentEventListener] 바이너리 데이터 storage에 저장 성공"); + } catch (Exception e) { + binaryContentService.updateBinaryContentStatus(binaryContentId, BinaryContentStatus.FAIL); + log.error("[BinaryContentEventListener] 바이너리 데이터 storage에 저장 중 예외 발생"); + } + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/event/listener/NotificationRequiredEventListener.java b/src/main/java/com/sprint/mission/discodeit/event/listener/NotificationRequiredEventListener.java new file mode 100644 index 000000000..3ee03668f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/event/listener/NotificationRequiredEventListener.java @@ -0,0 +1,83 @@ +package com.sprint.mission.discodeit.event.listener; + +import com.sprint.mission.discodeit.constant.ChannelErrorCode; +import com.sprint.mission.discodeit.constant.UserErrorCode; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.entity.Role; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.event.BinaryContentUploadFailureEvent; +import com.sprint.mission.discodeit.event.MessageCreatedEvent; +import com.sprint.mission.discodeit.event.RoleUpdatedEvent; +import com.sprint.mission.discodeit.exception.ChannelException; +import com.sprint.mission.discodeit.exception.UserException; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.NotificationRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.NotificationService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +//@Component +@RequiredArgsConstructor +public class NotificationRequiredEventListener { + + private final NotificationService notificationService; + private final ReadStatusRepository readStatusRepository; + private final NotificationRepository notificationRepository; + private final UserRepository userRepository; + private final ChannelRepository channelRepository; + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener + public void on(MessageCreatedEvent event) { + List readStatuses = readStatusRepository.findReadStatusByChannelId(event.getChannelId()); + User author = userRepository.findById(event.getAuthorId()) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + Channel channel = channelRepository.findById(event.getChannelId()) + .orElseThrow(() -> new ChannelException(ChannelErrorCode.CHANNEL_NOT_FOUND)); + String title = String.format("%s (#%s)", author.getUsername(), channel.getName()); + + readStatuses.stream() + .filter(readStatus -> !readStatus.getUserId().equals(event.getAuthorId())) + .forEach(readStatus -> + notificationService.create(readStatus.getUser(),title,event.getContent())); + } + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener + public void on(RoleUpdatedEvent event) { + User changedUser = userRepository.findById(event.getChangedUserId()) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + String title = "권한이 변경되었습니다."; + String content = String.format("%s -> %s", event.getOldRole().name(), event.getNewRole().name()); + notificationService.create(changedUser, title, content); + } + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener + public void on (BinaryContentUploadFailureEvent event) { + userRepository.findAllByRole(Role.ADMIN) + .forEach( + admin -> + { + String Content = String.format(""" + RequestId: %s + BinaryContentId: %s + Error: %s""",event.getRequestId(),event.getBinaryContentId(),event.getReason()); + notificationService.create(admin, "Binary Content 업로드 실패", Content); + } + ); + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/ApiControllerAdvice.java b/src/main/java/com/sprint/mission/discodeit/exception/ApiControllerAdvice.java deleted file mode 100644 index c3b7534ef..000000000 --- a/src/main/java/com/sprint/mission/discodeit/exception/ApiControllerAdvice.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.sprint.mission.discodeit.exception; - -import com.sprint.mission.discodeit.dto.ApiResponse; -import org.springframework.beans.TypeMismatchException; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.HttpStatusCode; -import org.springframework.http.ResponseEntity; -import org.springframework.http.converter.HttpMessageNotReadableException; -import org.springframework.validation.BindException; -import org.springframework.web.HttpRequestMethodNotSupportedException; -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; - -@RestControllerAdvice -public class ApiControllerAdvice extends ResponseEntityExceptionHandler { - - @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException ex) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(ApiResponse.onFailure(ex.getMessage())); - } - - @ExceptionHandler(HttpRequestMethodNotSupportedException.class) - public ResponseEntity> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { - return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED) - .body(ApiResponse.onFailure(e.getMessage())); - } - - /** - * @ModelAttribute 로 binding error 발생 시 - * 바인딩 필드 타입 불일치, 유효성 검사 길패 - */ - @ExceptionHandler(BindException.class) - public ResponseEntity> handleBindException(BindException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(ApiResponse.onFailure(e.getMessage())); - } - - /** - * 요청 파라미터가 컨트롤러 메서드의 파라미터 타입과 맞지 않을 때 - * String -> UUID로 받으려고 하거나 id=abc를 Long으로 받으려고 하거나 - */ - @Override - protected ResponseEntity handleTypeMismatch( - TypeMismatchException ex, - HttpHeaders headers, - HttpStatusCode status, - WebRequest request - ) { - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(ApiResponse.onFailure(ex.getMessage())); - } - - - /** - * JSON 파싱 에러 등 - */ - @Override - protected ResponseEntity handleHttpMessageNotReadable(HttpMessageNotReadableException ex, - HttpHeaders headers, HttpStatusCode status, - WebRequest request) { - - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(ApiResponse.onFailure(ex.getMessage())); - } - -} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/BinaryContentException.java b/src/main/java/com/sprint/mission/discodeit/exception/BinaryContentException.java new file mode 100644 index 000000000..8676e0edd --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/BinaryContentException.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.exception; + +import com.sprint.mission.discodeit.constant.ErrorCode; + +public class BinaryContentException extends BusinessException { + public BinaryContentException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/BusinessException.java b/src/main/java/com/sprint/mission/discodeit/exception/BusinessException.java new file mode 100644 index 000000000..cc0192d6a --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/BusinessException.java @@ -0,0 +1,27 @@ +package com.sprint.mission.discodeit.exception; + +import com.sprint.mission.discodeit.constant.ErrorCode; + +public class BusinessException extends RuntimeException { + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + this.errorCode = errorCode; + } + + public int getStatus() { + return errorCode.getStatus(); + } + + public String getMessage() { + return errorCode.getMessage(); + } + + public String getCode() { + return errorCode.getCode(); + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/ChannelException.java b/src/main/java/com/sprint/mission/discodeit/exception/ChannelException.java new file mode 100644 index 000000000..232910e18 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/ChannelException.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.exception; + +import com.sprint.mission.discodeit.constant.ErrorCode; + +public class ChannelException extends BusinessException { + public ChannelException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java b/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java new file mode 100644 index 000000000..f23325ff8 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/ErrorResponse.java @@ -0,0 +1,116 @@ +package com.sprint.mission.discodeit.exception; + +import com.sprint.mission.discodeit.constant.ClientRequestErrorCode; +import com.sprint.mission.discodeit.constant.ErrorCode; +import jakarta.validation.ConstraintViolation; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.validation.BindingResult; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ErrorResponse { + + private String message; + private int status; + private String code; + private List errors; + private List violationErrors; + + private ErrorResponse(ErrorCode code, List errors, List violationErrors) { + this.message = code.getMessage(); + this.status = code.getStatus(); + this.code = code.getCode(); + this.errors = errors; + this.violationErrors = violationErrors; + } + + private ErrorResponse(String message) { + this.message = message; + this.status = HttpStatus.BAD_REQUEST.value(); + this.code = HttpStatus.BAD_REQUEST.name(); + this.errors = new ArrayList<>(); + this.violationErrors = new ArrayList<>(); + } + + public static ErrorResponse of (String message) { + return new ErrorResponse(message); + } + + public static ErrorResponse of(ErrorCode code, BindingResult bindingResult) { + return new ErrorResponse(code, FieldError.of(bindingResult), new ArrayList<>()); + } + + public static ErrorResponse of(ErrorCode code) { + return new ErrorResponse(code, new ArrayList<>(), new ArrayList<>()); + } + + public static ErrorResponse ofFieldErrors(ErrorCode code, List errors) { + return new ErrorResponse(code, errors, new ArrayList<>()); + } + + public static ErrorResponse ofViolationErrors(ErrorCode code, List violationErrors) { + return new ErrorResponse(code, new ArrayList<>(), violationErrors); + } + + public static ErrorResponse of(MethodArgumentTypeMismatchException e) { + final String value = e.getValue() == null ? "" : e.getValue().toString(); + final List errors = ErrorResponse.FieldError.of(e.getName(), value, e.getErrorCode()); + return ErrorResponse.ofFieldErrors(ClientRequestErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH, errors); + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class FieldError { + private String field; + private String value; + private String reason; + + private FieldError(String field, String value, String reason) { + this.field = field; + this.value = value; + this.reason = reason; + } + + private static List of(String field, String value, String reason) { + List fieldErrors = new ArrayList<>(); + fieldErrors.add(new FieldError(field, value, reason)); + return fieldErrors; + } + + private static List of (BindingResult bindingResult) { + List fieldErrors = bindingResult.getFieldErrors(); + return fieldErrors.stream() + .map(error -> new FieldError( + error.getField(), + error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(), + error.getDefaultMessage())) + .collect(Collectors.toList()); + } + } + + @Getter + @AllArgsConstructor + public static class ConstraintViolationError { + private String propertyPath; + private Object rejectedValue; + private String reason; + + public static List of(Set> constraintViolations) { + return constraintViolations.stream() + .map(cv -> new ConstraintViolationError( + cv.getPropertyPath().toString(), + cv.getInvalidValue(), + cv.getMessage())) + .collect(Collectors.toList()); + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..f6d6ab0c5 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/GlobalExceptionHandler.java @@ -0,0 +1,70 @@ +package com.sprint.mission.discodeit.exception; + +import com.sprint.mission.discodeit.constant.AuthErrorCode; +import com.sprint.mission.discodeit.constant.ClientRequestErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception ex) { + log.error("=== 예외 발생 ===", ex); // 이 로그 추가! + log.error("예외 타입: {}", ex.getClass().getName()); + log.error("예외 메시지: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ErrorResponse.of(ClientRequestErrorCode.UKNOWN_ERROR)); + } + + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException ex) { + return ResponseEntity.status(ex.getStatus()) + .body(ErrorResponse.of(ex.getErrorCode())); + } + + /** + * @ModelAttribute 로 binding error 발생 시 + * 바인딩 필드 타입 불일치, 유효성 검사 길패 + */ + @ExceptionHandler(BindException.class) + public ResponseEntity handleBindException(BindException e) { + ErrorResponse errorResponse = ErrorResponse.of(ClientRequestErrorCode.INVALID_INPUT_VALUE, e.getBindingResult()); + return ResponseEntity.status(ClientRequestErrorCode.INVALID_INPUT_VALUE.getStatus()) + .body(errorResponse); + } + + /** + * JSON 파싱 에러 등 + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadable(HttpMessageNotReadableException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(ClientRequestErrorCode.INVALID_INPUT_VALUE)); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException ex) { + return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED) + .body(ErrorResponse.of(ClientRequestErrorCode.METHOD_NOT_ALLOWED)); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationExceptions(MethodArgumentNotValidException ex) { + ErrorResponse errorResponse = ErrorResponse.of(ex.getMessage()); + return ResponseEntity.status(AuthErrorCode.FORBIDDEN.getStatus()) + .body(errorResponse); + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/MessageException.java b/src/main/java/com/sprint/mission/discodeit/exception/MessageException.java new file mode 100644 index 000000000..ce0a227cf --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/MessageException.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.exception; + +import com.sprint.mission.discodeit.constant.ErrorCode; + +public class MessageException extends BusinessException { + public MessageException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/NotificationException.java b/src/main/java/com/sprint/mission/discodeit/exception/NotificationException.java new file mode 100644 index 000000000..ed9d8bad0 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/NotificationException.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.exception; + +import com.sprint.mission.discodeit.constant.ErrorCode; + +public class NotificationException extends BusinessException{ + public NotificationException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/ReadStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/ReadStatusException.java new file mode 100644 index 000000000..a576a309f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/ReadStatusException.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.exception; + +import com.sprint.mission.discodeit.constant.ErrorCode; + +public class ReadStatusException extends BusinessException { + public ReadStatusException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/TokenException.java b/src/main/java/com/sprint/mission/discodeit/exception/TokenException.java new file mode 100644 index 000000000..8404ee49d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/TokenException.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.exception; + +import com.sprint.mission.discodeit.constant.ErrorCode; + +public class TokenException extends BusinessException { + public TokenException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/UserAuthException.java b/src/main/java/com/sprint/mission/discodeit/exception/UserAuthException.java new file mode 100644 index 000000000..c496ecc5f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/UserAuthException.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.exception; + +import com.sprint.mission.discodeit.constant.ErrorCode; + +public class UserAuthException extends BusinessException { + public UserAuthException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/exception/UserException.java b/src/main/java/com/sprint/mission/discodeit/exception/UserException.java new file mode 100644 index 000000000..6ef63345d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/exception/UserException.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.exception; + +import com.sprint.mission.discodeit.constant.ErrorCode; + +public class UserException extends BusinessException { + public UserException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/factory/DefaultProfileFactory.java b/src/main/java/com/sprint/mission/discodeit/factory/DefaultProfileFactory.java deleted file mode 100644 index e3cb8d1b0..000000000 --- a/src/main/java/com/sprint/mission/discodeit/factory/DefaultProfileFactory.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.sprint.mission.discodeit.factory; - -import com.sprint.mission.discodeit.entity.BinaryContent; -import com.sprint.mission.discodeit.entity.FileType; -import java.util.UUID; - -public class DefaultProfileFactory { - - public static BinaryContent createDefaultProfile(UUID userId) { - byte[] defaultImage = new byte[0]; - return BinaryContent.builder() - .userId(userId) - .fileName("default-profile.png") - .fileType(FileType.PNG) - .data(defaultImage) - .build(); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java new file mode 100644 index 000000000..dfaf792f3 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/BinaryContentMapper.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponse; +import com.sprint.mission.discodeit.entity.BinaryContent; +import org.mapstruct.Mapper; + +@Mapper( + componentModel = "spring", + uses = {UserMapper.class} +) +public interface BinaryContentMapper { + + BinaryContentResponse toResponse(BinaryContent binaryContent); +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java new file mode 100644 index 000000000..30e809487 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java @@ -0,0 +1,65 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.channel.ChannelResponse; +import com.sprint.mission.discodeit.dto.channel.request.ChannelCreateServiceRequest; +import com.sprint.mission.discodeit.dto.channel.request.PrivateChannelCreateServiceRequest; +import com.sprint.mission.discodeit.dto.user.UserResponse; +import com.sprint.mission.discodeit.entity.BaseEntity; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.NullValuePropertyMappingStrategy; +import org.springframework.beans.factory.annotation.Autowired; + +@Mapper ( + componentModel = "spring", + nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.SET_TO_NULL +) +public abstract class ChannelMapper { + @Autowired + private MessageRepository messageRepository; + + @Autowired + private ReadStatusRepository readStatusRepository; + + @Autowired + private UserMapper userMapper; + + @Mapping(target = "name", source = "request.name") // request.name -> Channel.name + @Mapping(target = "description", source = "request.description") // request.description -> Channel.description + @Mapping(target = "type", source = "channelType") //channelType -> Channel.type + public abstract Channel toEntity(ChannelCreateServiceRequest request, ChannelType channelType); + + @Mapping(target = "name", ignore = true) + @Mapping(target = "description", ignore = true) + @Mapping(target = "type", source = "channelType") //channelType -> Channel.type + public abstract Channel toEntity(PrivateChannelCreateServiceRequest request, ChannelType channelType); + + + @Mapping(target = "participants", expression = "java(getUserResponses(channel))") + @Mapping(target = "lastMessageAt", expression = "java(getLastReadAt(channel))") + public abstract ChannelResponse toResponse(Channel channel); + + public Instant getLastReadAt(Channel channel) { + return messageRepository.findAllByChannelId(channel.getId()) + .stream() + .max(Comparator.comparing(BaseEntity::getCreatedAt)) + .map(BaseEntity::getCreatedAt) + .orElse(null); + } + + public List getUserResponses(Channel channel) { + return readStatusRepository.findReadStatusByChannelId(channel.getId()) + .stream() + .map(ReadStatus::getUser) + .map(userMapper::toResponse) + .toList(); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java new file mode 100644 index 000000000..86d36aa27 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/MessageMapper.java @@ -0,0 +1,37 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponse; +import com.sprint.mission.discodeit.dto.message.MessageResponse; +import com.sprint.mission.discodeit.dto.message.request.MessageCreateServiceRequest; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.MessageAttachment; +import com.sprint.mission.discodeit.entity.User; +import java.util.List; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper( + componentModel = "spring", + uses = {UserMapper.class, BinaryContentMapper.class} +) +public interface MessageMapper { + + @Mapping(target = "content", source = "request.message") // reqeust.message -> Message.content + @Mapping(target = "author", source = "user") // User -> Message.author + @Mapping(target = "channel", source = "channel") // Channel -> Message.channel + Message toEntity(MessageCreateServiceRequest request, User user, Channel channel); + + + @Mapping(target = "attachments", expression = "java(map(message.getAttachments()))") + @Mapping(target = "channelId", source = "channel.id") + MessageResponse toResponse(Message message); + + default List map(List messageAttachments) { + return messageAttachments.stream() + .map(MessageAttachment::getBinaryContent) + .map(BinaryContentResponse::new) + .toList(); + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/NotificationMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/NotificationMapper.java new file mode 100644 index 000000000..f06c12fa5 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/NotificationMapper.java @@ -0,0 +1,11 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.notification.NotificationDto; +import com.sprint.mission.discodeit.entity.Notification; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface NotificationMapper { + + NotificationDto toDto(Notification notification); +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java new file mode 100644 index 000000000..2ffbe5a87 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/PageResponseMapper.java @@ -0,0 +1,19 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.PageResponse; +import org.mapstruct.Mapper; +import org.springframework.data.domain.Slice; + +@Mapper(componentModel = "spring") +public interface PageResponseMapper { + + default PageResponse toPageResponse(Slice slice, Object nextCursor) { + return new PageResponse<>( + slice.getSize(), + slice.hasNext(), + slice.getContent(), + nextCursor, + null + ); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java new file mode 100644 index 000000000..ab8580f88 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/ReadStatusMapper.java @@ -0,0 +1,19 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.readstatus.ReadStatusResponse; +import com.sprint.mission.discodeit.dto.readstatus.request.ReadStatusCreateServiceRequest; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.entity.User; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface ReadStatusMapper { + + @Mapping(source = "user", target = "user") // User -> ReadStatus.user + @Mapping(source = "channel", target = "channel") // Channel -> ReadStatus.channel + ReadStatus toEntity(ReadStatusCreateServiceRequest readStatusCreateServiceRequest, User user, Channel channel); + + ReadStatusResponse toResponse(ReadStatus readStatus); +} diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java new file mode 100644 index 000000000..d8596cba7 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/mapper/UserMapper.java @@ -0,0 +1,33 @@ +package com.sprint.mission.discodeit.mapper; + +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponse; +import com.sprint.mission.discodeit.dto.user.UserResponse; +import com.sprint.mission.discodeit.dto.user.request.UserCreateServiceRequest; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.User; +import java.util.Optional; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.NullValuePropertyMappingStrategy; + +@Mapper( + componentModel = "spring", + nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.SET_TO_NULL, + uses = BinaryContentMapper.class +) +public interface UserMapper { + + + @Mapping(target = "profile", ignore = true) // 비즈니스 로직에서 처리 + @Mapping(target = "role", ignore = true) + User toEntity(UserCreateServiceRequest userCreateServiceRequest); + + @Mapping(target = "online", ignore = true) + @Mapping(target = "profile", expression = "java(map(user.getOptionalProfile()))") + UserResponse toResponse(User user); + + default BinaryContentResponse map(Optional binaryContent) { + return binaryContent.map(BinaryContentResponse::new).orElse(null); + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java index 9bda5e53b..cbd8c79cf 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/BinaryContentRepository.java @@ -1,24 +1,9 @@ package com.sprint.mission.discodeit.repository; import com.sprint.mission.discodeit.entity.BinaryContent; -import java.util.List; -import java.util.Optional; -import java.util.Set; import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; -public interface BinaryContentRepository { +public interface BinaryContentRepository extends JpaRepository { - void save(BinaryContent binaryContent); - - Optional findBinaryContentById(UUID id); - - Optional findBinaryContentsByUserId(UUID userId); - - Set findBinaryContents(); - - List findBinaryContentsByMessageId(UUID messageId); - - void deleteById(UUID binaryContentId); - - void deleteByUserId(UUID userId); } diff --git a/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java index 024a24bc2..65ed36a37 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/ChannelRepository.java @@ -1,18 +1,9 @@ package com.sprint.mission.discodeit.repository; import com.sprint.mission.discodeit.entity.Channel; -import java.util.Optional; -import java.util.Set; import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; -public interface ChannelRepository { - - void save(Channel channel); - - Optional findChannelById(UUID channelId); - - Set findChannels(); - - void delete(UUID channelId); +public interface ChannelRepository extends JpaRepository { } diff --git a/src/main/java/com/sprint/mission/discodeit/repository/MessageAttachmentRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/MessageAttachmentRepository.java new file mode 100644 index 000000000..d0473e80c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/MessageAttachmentRepository.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.MessageAttachment; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MessageAttachmentRepository extends JpaRepository { + +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java index e29c55e2c..89200f363 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/MessageRepository.java @@ -1,19 +1,24 @@ package com.sprint.mission.discodeit.repository; import com.sprint.mission.discodeit.entity.Message; -import java.util.Optional; -import java.util.Set; +import java.time.Instant; +import java.util.List; import java.util.UUID; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; -public interface MessageRepository { +public interface MessageRepository extends JpaRepository { - void save(Message message); - - Optional findMessageById(UUID messageId); - - Set findMessages(); + @Modifying + @Query("DELETE FROM Message m WHERE m.channel.id = :channelId") + void deleteAllByChannelId(UUID channelId); - void delete(UUID messageId); + @Query("SELECT m FROM Message m WHERE m.channel.id = :channelId") + List findAllByChannelId(UUID channelId); - void deleteAllByChannelId(UUID channelId); + @Query("SELECT m FROM Message m WHERE m.channel.id = :channelId AND m.createdAt < :cursor ORDER BY m.createdAt DESC") + Slice findChannelMessagesByCursor(UUID channelId, Instant cursor, Pageable pageable); } diff --git a/src/main/java/com/sprint/mission/discodeit/repository/NotificationRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/NotificationRepository.java new file mode 100644 index 000000000..9a0968b39 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/repository/NotificationRepository.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.repository; + +import com.sprint.mission.discodeit.entity.Notification; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface NotificationRepository extends JpaRepository { + @Query("SELECT n FROM Notification n WHERE n.receiver.id = :receiverId") + List findAllByReceiverId(UUID receiverId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java index 2531d07d4..65fabee70 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/ReadStatusRepository.java @@ -3,24 +3,26 @@ import com.sprint.mission.discodeit.entity.ReadStatus; import java.util.List; import java.util.Optional; -import java.util.Set; import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; -public interface ReadStatusRepository { - - void save(ReadStatus readStatus); - - Optional findReadStatusById(UUID id); - - Set findAllReadStatus(); +public interface ReadStatusRepository extends JpaRepository { + @Query("SELECT rs FROM ReadStatus rs WHERE rs.user.id = :userId") List findAllByUserId(UUID userId); - Optional findReadStatusByUserIdAndChannelId(UUID id, UUID channelId); + @Query("SELECT rs FROM ReadStatus rs WHERE rs.channel.id = :channelId") + List findReadStatusByChannelId(UUID channelId); - void deleteById(UUID id); + @Query("SELECT rs FROM ReadStatus rs WHERE rs.user.id = :id AND rs.channel.id = :channelId") + Optional findReadStatusByUserIdAndChannelId(UUID id, UUID channelId); + @Query("DELETE FROM ReadStatus rs WHERE rs.user.id = :userId AND rs.channel.id = :channelId") void deleteByUserIdAndChannelId(UUID userId, UUID channelId); + @Modifying + @Query("DELETE FROM ReadStatus rs WHERE rs.channel.id = :channelId") void deleteAllByChannelId(UUID channelId); } diff --git a/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java index e5fe80a39..8f0ea21e9 100644 --- a/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java +++ b/src/main/java/com/sprint/mission/discodeit/repository/UserRepository.java @@ -1,21 +1,17 @@ package com.sprint.mission.discodeit.repository; +import com.sprint.mission.discodeit.entity.Role; import com.sprint.mission.discodeit.entity.User; +import java.util.List; import java.util.Optional; -import java.util.Set; import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; -public interface UserRepository { +public interface UserRepository extends JpaRepository { - void save(User user); + Optional findByEmail(String email); - Optional findUserById(UUID userId); + Optional findByUsername(String username); - Optional findUserByEmail(String email); - - Optional findUserByUserName(String userName); - - Set findUsers(); - - void delete(UUID userId); + List findAllByRole(Role role); } diff --git a/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java deleted file mode 100644 index f65301e44..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/UserStatusRepository.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.sprint.mission.discodeit.repository; - -import com.sprint.mission.discodeit.entity.UserStatus; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; - -public interface UserStatusRepository { - - void save(UserStatus userStatus); - - Optional findUserStatusById(UUID id); - - Optional findUserStatusByUserId(UUID id); - - Set findUserStatuses(); - - void delete(UUID userId); -} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/file/FileBinaryContentRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/file/FileBinaryContentRepository.java deleted file mode 100644 index 8a0445f04..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/file/FileBinaryContentRepository.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.sprint.mission.discodeit.repository.file; - -import com.sprint.mission.discodeit.entity.BinaryContent; -import com.sprint.mission.discodeit.repository.BinaryContentRepository; -import com.sprint.mission.discodeit.util.FileUtils; -import jakarta.annotation.PostConstruct; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Repository; - -@Repository("fileBinaryContentRepository") -public class FileBinaryContentRepository implements BinaryContentRepository { - - private static Path directory; - - @Value("${discodeit.repository.binaryContent}") - private String binaryContentDir; - - public FileBinaryContentRepository() { - } - - @PostConstruct - private void init() { - directory = Paths.get(System.getProperty("user.dir"), binaryContentDir); - FileUtils.initDirectory(directory); - } - - @Override - public void save(BinaryContent binaryContent) { - Path filePath = directory.resolve(binaryContent.getId().toString().concat(".ser")); - FileUtils.save(filePath, binaryContent); - } - - @Override - public Set findBinaryContents() { return FileUtils.load(directory); } - - @Override - public Optional findBinaryContentById(UUID binaryContentId) { - return findBinaryContents().stream() - .filter(binaryContent -> binaryContent.getId().equals(binaryContentId)) - .findFirst(); - } - - @Override - public Optional findBinaryContentsByUserId(UUID userId) { - return findBinaryContents() - .stream() - .filter(binaryContent -> binaryContent.getUserId().equals(userId)) - .findFirst(); - } - - @Override - public List findBinaryContentsByMessageId(UUID messageId) { - return findBinaryContents() - .stream() - .filter(binaryContent -> binaryContent.getMessageId().equals(messageId)) - .toList(); - } - - @Override - public void deleteById(UUID binaryContentId) { - Path filePath = directory.resolve(binaryContentId.toString().concat(".ser")); - FileUtils.remove(filePath); - } - - @Override - public void deleteByUserId(UUID userId) { - Set binaryContents = findBinaryContents(); - - if(binaryContents.isEmpty()) { - return; - } - - binaryContents - .stream() - .filter(binaryContent -> binaryContent.getUserId().equals(userId)) - .map(BinaryContent::getId) - .forEach(this::deleteById); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/file/FileChannelRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/file/FileChannelRepository.java deleted file mode 100644 index 1b88326f6..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/file/FileChannelRepository.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.sprint.mission.discodeit.repository.file; - -import com.sprint.mission.discodeit.entity.Channel; -import com.sprint.mission.discodeit.repository.ChannelRepository; -import com.sprint.mission.discodeit.util.FileUtils; -import jakarta.annotation.PostConstruct; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Repository; - -@Repository("fileChannelRepository") -public class FileChannelRepository implements ChannelRepository { - - private static Path directory; - - @Value("${discodeit.repository.channel}") - private String channelDir; - - public FileChannelRepository() { - } - - @PostConstruct - private void init() { - directory = Paths.get(System.getProperty("user.dir"), channelDir); - FileUtils.initDirectory(directory); - } - - @Override - public void save(Channel channel) { - Path filePath = directory.resolve(channel.getId().toString().concat(".ser")); - FileUtils.save(filePath, channel); - } - - @Override - public Optional findChannelById(UUID channelId) { - return findChannels().stream() - .filter(channel -> channel.getId().equals(channelId)) - .findFirst(); - } - - @Override - public Set findChannels() { - return FileUtils.load(directory); - } - - @Override - public void delete(UUID channelId) { - Path filePath = directory.resolve(channelId.toString().concat(".ser")); - FileUtils.remove(filePath); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/file/FileMessageRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/file/FileMessageRepository.java deleted file mode 100644 index 74f61823d..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/file/FileMessageRepository.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.sprint.mission.discodeit.repository.file; - -import com.sprint.mission.discodeit.entity.Message; -import com.sprint.mission.discodeit.repository.MessageRepository; -import com.sprint.mission.discodeit.util.FileUtils; -import jakarta.annotation.PostConstruct; -import java.nio.file.Path; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Repository; - -@Repository("fileMessageRepository") -public class FileMessageRepository implements MessageRepository { - - private static Path directory; - - @Value("${discodeit.repository.message}") - private String messageDir; - - public FileMessageRepository() { - } - - @PostConstruct - private void init() { - directory = Path.of(System.getProperty("user.dir"), messageDir); - FileUtils.initDirectory(directory); - } - - @Override - public void save(Message message) { - Path filePath = directory.resolve(message.getId().toString().concat(".ser")); - FileUtils.save(filePath, message); - } - - @Override - public Optional findMessageById(UUID messageId) { - return findMessages().stream() - .filter(message -> message.getId().equals(messageId)) - .findFirst(); - } - - @Override - public Set findMessages() { - return FileUtils.load(directory); - } - - @Override - public void delete(UUID messageId) { - Path filePath = directory.resolve(messageId.toString().concat(".ser")); - FileUtils.remove(filePath); - } - - @Override - public void deleteAllByChannelId(UUID channelId) { - Path filePath = directory.resolve(channelId.toString().concat(".ser")); - findMessages().stream() - .filter(message -> message.getChannelId().equals(channelId)) - .forEach(message -> FileUtils.remove(filePath)); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/file/FileReadStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/file/FileReadStatusRepository.java deleted file mode 100644 index 13d26f461..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/file/FileReadStatusRepository.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.sprint.mission.discodeit.repository.file; - -import com.sprint.mission.discodeit.entity.ReadStatus; -import com.sprint.mission.discodeit.repository.ReadStatusRepository; -import com.sprint.mission.discodeit.util.FileUtils; -import jakarta.annotation.PostConstruct; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Repository; - -@Repository("fileReadStatusRepository") -public class FileReadStatusRepository implements ReadStatusRepository { - - private static Path directory; - - @Value("${discodeit.repository.readStatus}") - private String readStatusDir; - - public FileReadStatusRepository() { - } - - @PostConstruct - private void init() { - directory = Paths.get(System.getProperty("user.dir"), readStatusDir); - FileUtils.initDirectory(directory); - } - - @Override - public void save(ReadStatus readStatus) { - Path filePath = directory.resolve(readStatus.getId().toString().concat(".ser")); - FileUtils.save(filePath, readStatus); - } - - @Override - public Set findAllReadStatus() { - return FileUtils.load(directory); - } - - @Override - public Optional findReadStatusById(UUID id) { - return findAllReadStatus().stream() - .filter(readStatus -> readStatus.getId().equals(id)) - .findFirst(); - } - - @Override - public List findAllByUserId(UUID userId) { - return findAllReadStatus().stream() - .filter(readStatus -> readStatus.getUserId().equals(userId)) - .toList(); - } - - @Override - public Optional findReadStatusByUserIdAndChannelId(UUID userId, UUID channelId) { - return findAllReadStatus().stream() - .filter( - readStatus -> readStatus.getUserId().equals(userId) && - readStatus.getChannelId().equals(channelId) - ) - .findFirst(); - } - - @Override - public void deleteById(UUID id) { - directory.resolve(directory.resolve(id.toString().concat(".ser"))); - } - - @Override - public void deleteByUserIdAndChannelId(UUID userId, UUID channelId) { - findAllReadStatus().stream() - .filter( - readStatus -> readStatus.getId().equals(userId) && - readStatus.getChannelId().equals(channelId) - ) - .findFirst() - .ifPresent(readStatus -> directory.resolve(directory.resolve(readStatus.getId().toString().concat(".ser")))); - } - - @Override - public void deleteAllByChannelId(UUID channelId) { - findAllReadStatus().stream() - .filter(readStatus -> readStatus.getChannelId().equals(channelId)) - .map(ReadStatus::getId) - .forEach(this::deleteById); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/file/FileUserRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/file/FileUserRepository.java deleted file mode 100644 index ff928cdb2..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/file/FileUserRepository.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.sprint.mission.discodeit.repository.file; - -import com.sprint.mission.discodeit.entity.ActiveStatus; -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.repository.UserRepository; -import com.sprint.mission.discodeit.util.FileUtils; -import jakarta.annotation.PostConstruct; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Repository; - -@Repository("fileUserRepository") -public class FileUserRepository implements UserRepository { - - private static Path directory; - - @Value("${discodeit.repository.user}") - private String userDir; - - public FileUserRepository () { - } - - @PostConstruct - private void init() { - directory = Paths.get(System.getProperty("user.dir"), userDir); - FileUtils.initDirectory(directory); - } - - @Override - public void save(User user) { - Path filePath = directory.resolve(user.getId().toString().concat(".ser")); - FileUtils.save(filePath, user); - } - - @Override - public Optional findUserById(UUID userId) { - return findUsers().stream() - .filter( - user -> user.getId().equals(userId) && - user.getActiveStatus() == ActiveStatus.ACTIVE - ) - .findFirst(); - } - - @Override - public Optional findUserByEmail(String email) { - return findUsers().stream() - .filter( - user -> user.getEmail().equals(email) && - user.getActiveStatus() == ActiveStatus.ACTIVE - ) - .findFirst(); - } - - @Override - public Optional findUserByUserName(String userName) { - return findUsers().stream() - .filter( - user -> user.getUserName().equals(userName) && - user.getActiveStatus() == ActiveStatus.ACTIVE - ) - .findFirst(); - } - - @Override - public Set findUsers() { - return FileUtils.load(directory); - } - - @Override - public void delete(UUID userId) { - Path filePath = directory.resolve(userId.toString().concat(".ser")); - FileUtils.remove(filePath); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/file/FileUserStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/file/FileUserStatusRepository.java deleted file mode 100644 index 97583fa3d..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/file/FileUserStatusRepository.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.sprint.mission.discodeit.repository.file; - -import com.sprint.mission.discodeit.entity.UserStatus; -import com.sprint.mission.discodeit.repository.UserStatusRepository; -import com.sprint.mission.discodeit.util.FileUtils; -import jakarta.annotation.PostConstruct; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Repository; - -@Repository("fileUserStatusRepository") -public class FileUserStatusRepository implements UserStatusRepository { - - private static Path directory; - - @Value("${discodeit.repository.userStatus}") - private String userStatusDir; - - public FileUserStatusRepository() { - } - - @PostConstruct - private void init() { - directory = Paths.get(System.getProperty("user.dir"), userStatusDir); - FileUtils.initDirectory(directory); - } - - @Override - public void save(UserStatus userStatus) { - Path filePath = directory.resolve(userStatus.getId().toString().concat(".ser")); - FileUtils.save(filePath, userStatus); - } - - @Override - public Set findUserStatuses() { - return FileUtils.load(directory); - } - - @Override - public Optional findUserStatusById(UUID id) { - return findUserStatuses().stream() - .filter(userStatus -> userStatus.getId().equals(id)) - .findFirst(); - } - - @Override - public Optional findUserStatusByUserId(UUID id) { return findUserStatuses().stream().filter(userStatus -> userStatus.getUserId().equals(id)).findFirst(); } - - @Override - public void delete(UUID id) { - directory.resolve(directory.resolve(id.toString().concat(".ser"))); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFBinaryContentRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFBinaryContentRepository.java deleted file mode 100644 index fc2b8201d..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFBinaryContentRepository.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.sprint.mission.discodeit.repository.jcf; - -import com.sprint.mission.discodeit.entity.BinaryContent; -import com.sprint.mission.discodeit.repository.BinaryContentRepository; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import org.springframework.stereotype.Repository; - -@Repository("jcfBinaryContentRepository") -public class JCFBinaryContentRepository implements BinaryContentRepository { - - private final Set data; - - public JCFBinaryContentRepository() { - this.data = new LinkedHashSet<>(); - } - - @Override - public void save(BinaryContent binaryContent) { - data.add(binaryContent); - } - - @Override - public Optional findBinaryContentById(UUID id) { - return data.stream() - .filter(binaryContent -> binaryContent.getId().equals(id)) - .findFirst(); - } - - @Override - public Optional findBinaryContentsByUserId(UUID userId) { - return data.stream() - .filter(binaryContent -> binaryContent.getUserId().equals(userId)) - .findFirst(); - } - - @Override - public Set findBinaryContents() { - return data; - } - - @Override - public List findBinaryContentsByMessageId(UUID messageId) { - return data.stream() - .filter(binaryContent -> binaryContent.getMessageId().equals(messageId)) - .toList(); - } - - @Override - public void deleteById(UUID binaryContentId) { - data.removeIf(binaryContent -> binaryContent.getId().equals(binaryContentId)); - } - - @Override - public void deleteByUserId(UUID userId) { - data.stream() - .filter(binaryContent -> binaryContent.getUserId().equals(userId)) - .map(BinaryContent::getId) - .forEach(this::deleteById); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFChannelRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFChannelRepository.java deleted file mode 100644 index ee9d1a073..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFChannelRepository.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.sprint.mission.discodeit.repository.jcf; - -import com.sprint.mission.discodeit.entity.Channel; -import com.sprint.mission.discodeit.repository.ChannelRepository; -import java.util.LinkedHashSet; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import org.springframework.stereotype.Repository; - -@Repository("jcfChannelRepository") -public class JCFChannelRepository implements ChannelRepository { - - private final Set data; - - public JCFChannelRepository() { - this.data = new LinkedHashSet<>(); - } - - @Override - public void save(Channel channel) { - data.add(channel); - } - - @Override - public Optional findChannelById(UUID channelId) { - return data.stream() - .filter(channel -> channel.getId().equals(channelId)) - .findFirst(); - } - - - @Override - public Set findChannels() { - return data; - } - - @Override - public void delete(UUID channerlId) { - data.removeIf(channel -> channel.getId().equals(channerlId)); - - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFMessageRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFMessageRepository.java deleted file mode 100644 index 1772bb675..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFMessageRepository.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.sprint.mission.discodeit.repository.jcf; - -import com.sprint.mission.discodeit.entity.Message; -import com.sprint.mission.discodeit.repository.MessageRepository; -import java.util.LinkedHashSet; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import org.springframework.stereotype.Repository; - -@Repository("jcfMessageRepository") -public class JCFMessageRepository implements MessageRepository { - - private final Set data; - - public JCFMessageRepository() { - this.data = new LinkedHashSet<>(); - } - - @Override - public void save(Message message) { - data.add(message); - } - - @Override - public Optional findMessageById(UUID messageId) { - return data.stream() - .filter(message -> message.getId() == messageId) - .findFirst(); - } - - @Override - public Set findMessages() { - return data; - } - - @Override - public void delete(UUID messageId) { - data.removeIf(message -> message.getId() == messageId); - } - - @Override - public void deleteAllByChannelId(UUID channelId) { - data.removeIf(message -> message.getChannelId().equals(channelId)); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFReadStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFReadStatusRepository.java deleted file mode 100644 index d53122148..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFReadStatusRepository.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.sprint.mission.discodeit.repository.jcf; - -import com.sprint.mission.discodeit.entity.ReadStatus; -import com.sprint.mission.discodeit.repository.ReadStatusRepository; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import org.springframework.stereotype.Repository; - -@Repository("jcfReadStatusRepository") -public class JCFReadStatusRepository implements ReadStatusRepository { - - private final Set data; - - public JCFReadStatusRepository() { - this.data = new LinkedHashSet<>(); - } - - @Override - public void save(ReadStatus readStatus) { - data.add(readStatus); - } - - @Override - public Optional findReadStatusById(UUID id) { - return data.stream() - .filter(readStatus -> readStatus.getId().equals(id)) - .findFirst(); - } - - @Override - public Set findAllReadStatus() { return data; } - - @Override - public List findAllByUserId(UUID userId) { - return data.stream() - .filter(readStatus -> readStatus.getUserId().equals(userId)) - .toList(); - } - - @Override - public Optional findReadStatusByUserIdAndChannelId(UUID id, UUID channelId) { - return data.stream() - .filter( - readStatus -> readStatus.getUserId().equals(id) && - readStatus.getChannelId().equals(channelId) - ) - .findFirst(); - } - - @Override - public void deleteById(UUID id) { - data.removeIf(readStatus -> readStatus.getId().equals(id)); - } - - @Override - public void deleteByUserIdAndChannelId(UUID userId, UUID channelId) { - data.removeIf(readStatus -> readStatus.getUserId().equals(userId) && readStatus.getChannelId().equals(channelId)); - } - - @Override - public void deleteAllByChannelId(UUID channelId) { - data.stream() - .filter(readStatus -> readStatus.getChannelId().equals(channelId)) - .map(ReadStatus::getId) - .forEach(this::deleteById); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFUserRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFUserRepository.java deleted file mode 100644 index 60427bb43..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFUserRepository.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.sprint.mission.discodeit.repository.jcf; - -import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.repository.UserRepository; -import java.util.LinkedHashSet; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import org.springframework.stereotype.Repository; - -@Repository("jcfUserRepository") -public class JCFUserRepository implements UserRepository { - - private final Set data; - - public JCFUserRepository() { - this.data = new LinkedHashSet<>(); - } - - @Override - public void save(User user) { - data.add(user); - } - - @Override - public Set findUsers() { return data; } - - @Override - public Optional findUserById(UUID userId) { - return data.stream() - .filter(user -> user.getId() == userId) - .findFirst(); - } - - @Override - public Optional findUserByEmail(String email) { - return data.stream() - .filter(user -> user.getEmail().equals(email)) - .findFirst(); - } - - @Override - public Optional findUserByUserName(String userName) { - return data.stream() - .filter(user -> user.getUserName().equals(userName)) - .findFirst(); - } - - @Override - public void delete(UUID userId) { - data.removeIf(user -> user.getId().equals(userId)); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFUserStatusRepository.java b/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFUserStatusRepository.java deleted file mode 100644 index e56e1148d..000000000 --- a/src/main/java/com/sprint/mission/discodeit/repository/jcf/JCFUserStatusRepository.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.sprint.mission.discodeit.repository.jcf; - -import com.sprint.mission.discodeit.entity.UserStatus; -import com.sprint.mission.discodeit.repository.UserStatusRepository; -import java.util.LinkedHashSet; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import org.springframework.stereotype.Repository; - -@Repository("jcfUserStatusRepository") -public class JCFUserStatusRepository implements UserStatusRepository { - - private final Set data; - - public JCFUserStatusRepository() { - this.data = new LinkedHashSet<>(); - } - - @Override - public void save(UserStatus userStatus) { - data.add(userStatus); - } - - @Override - public Optional findUserStatusById(UUID id) { - return data.stream() - .filter(userStatus -> userStatus.getId().equals(id)) - .findFirst(); - } - - @Override - public Optional findUserStatusByUserId(UUID userId) { - return data.stream() - .filter(userStatus -> userStatus.getUserId().equals(userId)) - .findFirst(); - } - - @Override - public Set findUserStatuses() { - return data; - } - - @Override - public void delete(UUID userId) { - data.removeIf(userStatus -> userStatus.getUserId().equals(userId)); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/security/AdminInitializer.java b/src/main/java/com/sprint/mission/discodeit/security/AdminInitializer.java new file mode 100644 index 000000000..c1efd3c2c --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/AdminInitializer.java @@ -0,0 +1,20 @@ +package com.sprint.mission.discodeit.security; + +import com.sprint.mission.discodeit.service.AuthService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +@RequiredArgsConstructor +public class AdminInitializer implements CommandLineRunner { + + private final AuthService authService; + + @Override + public void run(String... args) { + authService.registerAdmin(); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetails.java b/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetails.java new file mode 100644 index 000000000..4eead48ab --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetails.java @@ -0,0 +1,47 @@ +package com.sprint.mission.discodeit.security; + +import com.sprint.mission.discodeit.dto.user.UserResponse; +import java.util.Collection; +import java.util.Objects; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.userdetails.UserDetails; + +@Getter +@RequiredArgsConstructor +public class DiscodeitUserDetails implements UserDetails { + private final UserResponse userResponse; + private final String password; + + @Override + public Collection getAuthorities() { + return AuthorityUtils.createAuthorityList("ROLE_".concat(userResponse.getRole().name())); + } + + @Override + public String getUsername() { + return userResponse.getUsername(); + } + + @Override + public final boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof DiscodeitUserDetails that)) { + return false; + } + + return Objects.equals(userResponse, that.userResponse) && Objects.equals(password, + that.password); + } + + @Override + public int hashCode() { + int result = Objects.hashCode(userResponse); + result = 31 * result + Objects.hashCode(password); + return result; + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetailsService.java b/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetailsService.java new file mode 100644 index 000000000..e0c99e7be --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/DiscodeitUserDetailsService.java @@ -0,0 +1,29 @@ +package com.sprint.mission.discodeit.security; + +import com.sprint.mission.discodeit.constant.UserErrorCode; +import com.sprint.mission.discodeit.dto.user.UserResponse; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.UserException; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class DiscodeitUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + private final UserMapper userMapper; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByUsername(username).orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + UserResponse userResponse = userMapper.toResponse(user); + return new DiscodeitUserDetails(userResponse, user.getPassword()); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/HttpStatusReturningLogoutSuccessHandler.java b/src/main/java/com/sprint/mission/discodeit/security/HttpStatusReturningLogoutSuccessHandler.java new file mode 100644 index 000000000..b3b27105a --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/HttpStatusReturningLogoutSuccessHandler.java @@ -0,0 +1,23 @@ +package com.sprint.mission.discodeit.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class HttpStatusReturningLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { + private final ObjectMapper objectMapper; + + @Override + public void onLogoutSuccess(HttpServletRequest reqeust, HttpServletResponse response, Authentication authentication) + throws IOException, ServletException { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/LoginFailureHandler.java b/src/main/java/com/sprint/mission/discodeit/security/LoginFailureHandler.java new file mode 100644 index 000000000..666a37f80 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/LoginFailureHandler.java @@ -0,0 +1,28 @@ +package com.sprint.mission.discodeit.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.constant.AuthErrorCode; +import com.sprint.mission.discodeit.exception.ErrorResponse; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class LoginFailureHandler implements AuthenticationFailureHandler { + + private final ObjectMapper objectMapper; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write(objectMapper.writeValueAsString(ErrorResponse.of(AuthErrorCode.AUTHENTICATION_FAILED))); + } +} + diff --git a/src/main/java/com/sprint/mission/discodeit/security/LoginSuccessHandler.java b/src/main/java/com/sprint/mission/discodeit/security/LoginSuccessHandler.java new file mode 100644 index 000000000..b8e45f410 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/LoginSuccessHandler.java @@ -0,0 +1,28 @@ +package com.sprint.mission.discodeit.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class LoginSuccessHandler implements AuthenticationSuccessHandler { + + private final ObjectMapper objectMapper; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + DiscodeitUserDetails userDetails = (DiscodeitUserDetails) authentication.getPrincipal(); + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write(objectMapper.writeValueAsString(userDetails.getUserResponse())); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/SpaCsrfTokenRequestHandler.java b/src/main/java/com/sprint/mission/discodeit/security/SpaCsrfTokenRequestHandler.java new file mode 100644 index 000000000..7a53ef9c0 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/SpaCsrfTokenRequestHandler.java @@ -0,0 +1,47 @@ +package com.sprint.mission.discodeit.security; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.function.Supplier; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; +import org.springframework.security.web.csrf.CsrfTokenRequestHandler; +import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +public class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler { + private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler(); + private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler(); + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, Supplier csrfToken) { + /* + * Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of + * the CsrfToken when it is rendered in the response body. + */ + this.xor.handle(request, response, csrfToken); + /* + * Render the token value to a cookie by causing the deferred token to be loaded. + */ + csrfToken.get(); + } + + @Override + public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) { + String headerValue = request.getHeader(csrfToken.getHeaderName()); + /* + * If the request contains a request header, use CsrfTokenRequestAttributeHandler + * to resolve the CsrfToken. This applies when a single-page application includes + * the header value automatically, which was obtained via a cookie containing the + * raw CsrfToken. + * + * In all other cases (e.g. if the request contains a request parameter), use + * XorCsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies + * when a server-side rendered form includes the _csrf request parameter as a + * hidden input. + */ + return (StringUtils.hasText(headerValue) ? this.plain : this.xor).resolveCsrfTokenValue(request, csrfToken); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/jwt/InMemoryJwtRegistry.java b/src/main/java/com/sprint/mission/discodeit/security/jwt/InMemoryJwtRegistry.java new file mode 100644 index 000000000..c66ce201d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/jwt/InMemoryJwtRegistry.java @@ -0,0 +1,161 @@ +package com.sprint.mission.discodeit.security.jwt; + +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class InMemoryJwtRegistry implements JwtRegistry { + private final Map> origin = new ConcurrentHashMap<>(); + + private final Set accessTokenIndexes = ConcurrentHashMap.newKeySet(); + + private final Set refreshTokenIndexes = ConcurrentHashMap.newKeySet(); + + private final int maxActiveJwtCount; + + private final JwtProvider tokenProvider; + + public InMemoryJwtRegistry(@Value("${jwt.max-active-count:1}") int maxActiveJwtCount, JwtProvider provider) { + this.maxActiveJwtCount = maxActiveJwtCount; + this.tokenProvider = provider; + } + + @Override + public void registerJwtInformation(JwtInformation jwtInformation) { + origin.compute(jwtInformation.getUserResponse().getId(), (key, queue) -> { + if (queue == null) { + queue = new ConcurrentLinkedQueue<>(); + } + if (queue.size() >= maxActiveJwtCount) { + JwtInformation deprecatedJwtInformation = queue.poll(); + if (deprecatedJwtInformation != null) { + removeTokenIndex( + deprecatedJwtInformation.getAccessToken(), + deprecatedJwtInformation.getRefreshToken() + ); + log.debug("최대 토큰 개수 초과로 오래된 토큰 제거: userId={}", key); + } + } + queue.add(jwtInformation); + addTokenIndex( + jwtInformation.getAccessToken(), + jwtInformation.getRefreshToken() + ); + log.debug("JWT 등록 완료: userId={}, 현재 토큰 수={}", key, queue.size()); + return queue; + }); + } + + @Override + public void invalidateJwtInformationByUserId(UUID userId) { + Queue removed = origin.remove(userId); + if (removed != null) { + removed.forEach(info -> removeTokenIndex(info.getAccessToken(), info.getRefreshToken())); + log.debug("사용자의 모든 JWT 무효화: userId={}, 제거된 토큰 수={}", userId, removed.size()); + } + } + + @Override + public boolean hasActiveJwtInformationByUserId(UUID userId) { + Queue queue = origin.get(userId); + return queue != null && !queue.isEmpty(); + } + + @Override + public boolean hasActiveJwtInformationByAccessToken(String accessToken) { + return accessTokenIndexes.contains(accessToken); + } + + @Override + public boolean hasActiveJwtInformationByRefreshToken(String refreshToken) { + return refreshTokenIndexes.contains(refreshToken); + } + + @Override + public void rotateJwtInformation(String refreshToken, JwtInformation newJwtInformation) { + origin.computeIfPresent(newJwtInformation.getUserResponse().getId(), (key, queue) -> { + // 기존 방식의 문제: rotate() 반환값을 사용하지 않음 + // 해결: 큐에서 제거 후 새로 추가 + + boolean rotated = false; + Queue newQueue = new ConcurrentLinkedQueue<>(); + + for (JwtInformation jwtInfo : queue) { + if (jwtInfo.getRefreshToken().equals(refreshToken)) { + // 기존 토큰 인덱스 제거 + removeTokenIndex(jwtInfo.getAccessToken(), jwtInfo.getRefreshToken()); + + // 새 토큰 추가 + newQueue.add(newJwtInformation); + addTokenIndex( + newJwtInformation.getAccessToken(), + newJwtInformation.getRefreshToken() + ); + rotated = true; + log.debug("JWT 로테이션 완료: userId={}", key); + } else { + newQueue.add(jwtInfo); + } + } + + if (!rotated) { + log.warn("로테이션 대상 RefreshToken을 찾을 수 없음: userId={}", key); + } + + return newQueue; + }); + } + + @Scheduled(fixedDelay = 1000 * 60 * 5) + @Override + public void clearExpiredJwtInformation() { + AtomicInteger totalCleaned = new AtomicInteger(); + + origin.entrySet().removeIf(entry -> { + Queue queue = entry.getValue(); + int beforeSize = queue.size(); + + queue.removeIf(jwtInformation -> { + boolean isExpired = + !tokenProvider.validateToken(jwtInformation.getAccessToken()) || + !tokenProvider.validateToken(jwtInformation.getRefreshToken()); + if (isExpired) { + removeTokenIndex( + jwtInformation.getAccessToken(), + jwtInformation.getRefreshToken() + ); + } + return isExpired; + }); + + int cleaned = beforeSize - queue.size(); + totalCleaned.addAndGet(cleaned); + + return queue.isEmpty(); + }); + + if (totalCleaned.get() > 0) { + log.info("만료된 JWT 정리 완료: {}개 제거", totalCleaned); + } + } + + private void addTokenIndex(String accessToken, String refreshToken) { + accessTokenIndexes.add(accessToken); + refreshTokenIndexes.add(refreshToken); + } + + private void removeTokenIndex(String accessToken, String refreshToken) { + accessTokenIndexes.remove(accessToken); + refreshTokenIndexes.remove(refreshToken); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 000000000..5df9cd550 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,59 @@ +package com.sprint.mission.discodeit.security.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + private final UserDetailsService userDetailsService; + private final JwtRegistry jwtRegistry; // 추가 + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER); + + if (authorizationHeader != null && authorizationHeader.startsWith(BEARER_PREFIX)) { + String token = authorizationHeader.substring(BEARER_PREFIX.length()); + + // 토큰 유효성 검증 + Registry 확인 + if (jwtProvider.validateToken(token) && jwtRegistry.hasActiveJwtInformationByAccessToken(token)) { + String username = jwtProvider.getUsername(token); + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + + log.debug("JWT 인증 성공: {}", username); + } else { + log.debug("JWT 인증 실패: 토큰 무효 또는 Registry에 없음"); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtDto.java b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtDto.java new file mode 100644 index 000000000..8025e525f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtDto.java @@ -0,0 +1,12 @@ +package com.sprint.mission.discodeit.security.jwt; + +import com.sprint.mission.discodeit.dto.user.UserResponse; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class JwtDto { + private final UserResponse userDto; + private final String accessToken; +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtInformation.java b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtInformation.java new file mode 100644 index 000000000..f3825da09 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtInformation.java @@ -0,0 +1,20 @@ +package com.sprint.mission.discodeit.security.jwt; + +import com.sprint.mission.discodeit.dto.user.UserResponse; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class JwtInformation { + + private final UserResponse userResponse; + + private final String accessToken; + + private final String refreshToken; + + public JwtInformation rotate(String accessToken, String refreshToken) { + return new JwtInformation(userResponse, accessToken, refreshToken); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtLoginSuccessHandler.java b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtLoginSuccessHandler.java new file mode 100644 index 000000000..6c6eaa4f0 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtLoginSuccessHandler.java @@ -0,0 +1,78 @@ +package com.sprint.mission.discodeit.security.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jose.JOSEException; +import com.sprint.mission.discodeit.constant.AuthErrorCode; +import com.sprint.mission.discodeit.constant.TokenErrorCode; +import com.sprint.mission.discodeit.exception.TokenException; +import com.sprint.mission.discodeit.exception.UserAuthException; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtLoginSuccessHandler implements AuthenticationSuccessHandler { + + private final JwtProvider jwtProvider; + private final ObjectMapper objectMapper; + private final JwtRegistry jwtRegistry; + private final CacheManager cacheManager; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException { + + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + if (!(authentication.getPrincipal() instanceof DiscodeitUserDetails userDetails)) { + throw new UserAuthException(AuthErrorCode.INVALID_USER); + } + + try { + String accessToken = jwtProvider.createAccessToken(userDetails); + String refreshToken = jwtProvider.createRefreshToken(userDetails); + + Cookie refreshCookie = jwtProvider.createRefreshTokenCookie(refreshToken); + response.addCookie(refreshCookie); + + JwtDto jwtDto = new JwtDto( + userDetails.getUserResponse(), + accessToken + ); + + JwtInformation jwtInformation = new JwtInformation( + userDetails.getUserResponse(), + accessToken, + refreshToken + ); + jwtRegistry.registerJwtInformation(jwtInformation); + + response.setStatus(HttpServletResponse.SC_OK); + objectMapper.writeValue(response.getWriter(), jwtDto); + + Cache userCache = cacheManager.getCache("users"); + if(userCache != null) { + userCache.clear(); + } + + log.debug("JWT 토큰이 발급 사용자: {}", userDetails.getUsername()); + + } catch (JOSEException e) { + log.error("JWT 토큰 생성 실패: 사용자 {}", userDetails.getUsername(), e); + throw new TokenException(TokenErrorCode.TOKEN_CREATION_ERROR); + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtLogoutHandler.java b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtLogoutHandler.java new file mode 100644 index 000000000..55b10f05f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtLogoutHandler.java @@ -0,0 +1,52 @@ +package com.sprint.mission.discodeit.security.jwt; + +import com.nimbusds.jwt.SignedJWT; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Arrays; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtLogoutHandler implements LogoutHandler { + + private final JwtProvider jwtProvider; + private final JwtRegistry jwtRegistry; + private final CacheManager cacheManager; + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) { + + Cookie refreshTokenExpirationCookie = jwtProvider.createRefreshTokenExpirationCookie(); + response.addCookie(refreshTokenExpirationCookie); + if (request.getCookies() != null) { + Arrays.stream(request.getCookies()) + .filter(cookie -> cookie.getName().equals(JwtProvider.REFRESH_TOKEN_COOKIE_NAME)) + .findFirst() + .ifPresent(cookie -> { + try { + SignedJWT signedJWT = SignedJWT.parse(cookie.getValue()); + String userId = signedJWT.getJWTClaimsSet().getStringClaim("userId"); + jwtRegistry.invalidateJwtInformationByUserId(UUID.fromString(userId)); + } catch (Exception e) { + log.warn("Failed to invalidate JWT information on logout", e); + } + }); + } + Cache userCache = cacheManager.getCache("users"); + if(userCache != null) { + userCache.clear(); + } + log.debug("JWT 로그아웃 핸들러 실행 - 리프레시 토큰 쿠키 삭제"); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtProvider.java b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtProvider.java new file mode 100644 index 000000000..bbbf17c24 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtProvider.java @@ -0,0 +1,133 @@ +package com.sprint.mission.discodeit.security.jwt; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.MACVerifier; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.sprint.mission.discodeit.dto.user.UserResponse; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import jakarta.servlet.http.Cookie; +import java.text.ParseException; +import java.util.Base64; +import java.util.Date; +import java.util.UUID; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class JwtProvider { + + public static final String REFRESH_TOKEN_COOKIE_NAME = "REFRESH_TOKEN"; + + @Getter + private final int accessTokenExpiration; + + @Getter + private final int refreshTokenExpiration; + + private final JWSSigner signer; + private final JWSVerifier verifier; + + public JwtProvider( + @Value("${jwt.secret}") String secret, + @Value("${jwt.access-token-expiration}") int accessTokenExpiration, + @Value("${jwt.refresh-token-expiration}") int refreshTokenExpiration + ) throws JOSEException { + this.accessTokenExpiration = accessTokenExpiration; + this.refreshTokenExpiration = refreshTokenExpiration; + + byte[] secretBytes = Base64.getDecoder().decode(secret); + this.signer = new MACSigner(secretBytes); + this.verifier = new MACVerifier(secretBytes); + } + + public String createAccessToken(DiscodeitUserDetails userDetails) throws JOSEException { + return createToken(userDetails, accessTokenExpiration, signer); + } + + public String createRefreshToken(DiscodeitUserDetails userDetails) throws JOSEException { + return createToken(userDetails, refreshTokenExpiration, signer); + } + + private String createToken(DiscodeitUserDetails userDetails, int expiration, JWSSigner signer) + throws JOSEException { + String tokenId = UUID.randomUUID().toString(); + UserResponse user = userDetails.getUserResponse(); + + Date now = new Date(); + Date expirationDate = new Date(now.getTime() + expiration * 1000L); + + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .subject(user.getUsername()) + .issueTime(now) + .jwtID(tokenId) + .claim("id", user.getId()) + .expirationTime(expirationDate) + .build(); + SignedJWT signedJWT = new SignedJWT( + new JWSHeader(JWSAlgorithm.HS256), + claimsSet + ); + + signedJWT.sign(signer); + return signedJWT.serialize(); + } + + public boolean validateToken(String token) { + try { + SignedJWT signedJWT = SignedJWT.parse(token); + + if (!signedJWT.verify(verifier)) { + return false; + } + + Date expiration = signedJWT.getJWTClaimsSet().getExpirationTime(); + return expiration != null && expiration.after(new Date()); + + } catch (Exception e) { + return false; + } + } + + public String getUsername(String token) { + try { + SignedJWT signedJWT = SignedJWT.parse(token); + return signedJWT.getJWTClaimsSet().getSubject(); + } catch (ParseException e) { + throw new RuntimeException("JWT 토큰 파싱 실패", e); + } + } + + + public Cookie createRefreshTokenCookie(String refreshToken) { + Cookie refreshCookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, refreshToken); + refreshCookie.setHttpOnly(true); + refreshCookie.setPath("/"); + refreshCookie.setMaxAge(refreshTokenExpiration / 1000); + return refreshCookie; + } + + public Cookie createRefreshTokenExpirationCookie() { + Cookie refreshCookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, ""); + refreshCookie.setHttpOnly(true); + refreshCookie.setSecure(true); // 운영 HTTPS 사용 + refreshCookie.setPath("/"); + refreshCookie.setMaxAge(0); + return refreshCookie; + } + + public String getUsernameFromToken(String token) { + try { + SignedJWT signedJWT = SignedJWT.parse(token); + return signedJWT.getJWTClaimsSet().getSubject(); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtRegistry.java b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtRegistry.java new file mode 100644 index 000000000..b290fdb3d --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/security/jwt/JwtRegistry.java @@ -0,0 +1,20 @@ +package com.sprint.mission.discodeit.security.jwt; + +import java.util.UUID; + +public interface JwtRegistry { + + void registerJwtInformation(JwtInformation jwtInformation); + + void invalidateJwtInformationByUserId(UUID userId); + + boolean hasActiveJwtInformationByUserId(UUID userId); + + boolean hasActiveJwtInformationByAccessToken(String accessToken); + + boolean hasActiveJwtInformationByRefreshToken(String refreshToken); + + void rotateJwtInformation(String refreshToken, JwtInformation newJwtInformation); + + void clearExpiredJwtInformation(); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/AuthService.java b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java index 4a4f49f76..d1fbde0b1 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/AuthService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/AuthService.java @@ -1,9 +1,14 @@ package com.sprint.mission.discodeit.service; -import com.sprint.mission.discodeit.dto.auth.request.SignIn; +import com.sprint.mission.discodeit.dto.auth.request.RoleUpdateRequest; import com.sprint.mission.discodeit.dto.user.UserResponse; +import com.sprint.mission.discodeit.security.jwt.JwtInformation; public interface AuthService { - UserResponse login(SignIn signIn); + UserResponse updateRole(RoleUpdateRequest roleUpdateRequest); + + void registerAdmin(); + + JwtInformation refreshToken(String refreshToken); } diff --git a/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java b/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java index 26b2cd55e..aa9e3d38e 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/BinaryContentService.java @@ -2,8 +2,10 @@ import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponse; import com.sprint.mission.discodeit.dto.binarycontent.request.BinaryContentCreateServiceRequest; +import com.sprint.mission.discodeit.entity.BinaryContentStatus; import java.util.List; import java.util.UUID; +import org.springframework.http.ResponseEntity; public interface BinaryContentService { @@ -14,4 +16,8 @@ public interface BinaryContentService { List findAllByIdIn(List ids); void deleteById(UUID binaryContentId); + + BinaryContentResponse updateBinaryContentStatus(UUID binaryContentId, BinaryContentStatus binaryContentStatus); + + ResponseEntity download(BinaryContentResponse response); } diff --git a/src/main/java/com/sprint/mission/discodeit/service/MessageService.java b/src/main/java/com/sprint/mission/discodeit/service/MessageService.java index 81f75f1c0..35d48e87e 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/MessageService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/MessageService.java @@ -1,10 +1,13 @@ package com.sprint.mission.discodeit.service; +import com.sprint.mission.discodeit.dto.PageResponse; import com.sprint.mission.discodeit.dto.message.MessageResponse; import com.sprint.mission.discodeit.dto.message.request.MessageCreateServiceRequest; import com.sprint.mission.discodeit.dto.message.request.MessageUpdateServiceRequest; +import java.time.Instant; import java.util.List; import java.util.UUID; +import org.springframework.data.domain.Pageable; public interface MessageService { @@ -14,7 +17,7 @@ public interface MessageService { //조회 MessageResponse findMessageById(UUID messageId); - List findMessagesByChannelId(UUID channelId); + PageResponse findMessagesByChannelId(UUID channelId, Instant cursor, Pageable pageable); List findMessagesByUserId(UUID userId); diff --git a/src/main/java/com/sprint/mission/discodeit/service/NotificationService.java b/src/main/java/com/sprint/mission/discodeit/service/NotificationService.java new file mode 100644 index 000000000..36205c28b --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/NotificationService.java @@ -0,0 +1,15 @@ +package com.sprint.mission.discodeit.service; + +import com.sprint.mission.discodeit.dto.notification.NotificationDto; +import com.sprint.mission.discodeit.entity.User; +import java.util.List; +import java.util.UUID; + +public interface NotificationService { + + public void create(User receiver, String title, String content); + + public List getNotifications(UUID userId); + + public void deleteNotification(UUID notificationId, UUID userId); +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/UserService.java b/src/main/java/com/sprint/mission/discodeit/service/UserService.java index 256c4a944..396fe9aca 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/UserService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/UserService.java @@ -14,16 +14,8 @@ public interface UserService { //조회 UserResponse findUserById(UUID userId); - UserResponse findDormantUserById(UUID userId); - - UserResponse findDeletedUserById(UUID userId); - List findUsers(); - List findDormantUsers(); - - List findDeletedUsers(); - //수정 UserResponse updateUser(UserUpdateServiceRequest request); //삭제 diff --git a/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java deleted file mode 100644 index 4f8038d3c..000000000 --- a/src/main/java/com/sprint/mission/discodeit/service/UserStatusService.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.sprint.mission.discodeit.service; - -import com.sprint.mission.discodeit.dto.userstatus.UserStatusResponse; -import com.sprint.mission.discodeit.dto.userstatus.request.UserStatusCreateServiceRequest; -import com.sprint.mission.discodeit.dto.userstatus.request.UserStatusUpdateServiceRequest; -import java.util.List; -import java.util.UUID; - -public interface UserStatusService { - - void createUserStatus(UserStatusCreateServiceRequest request); - - UserStatusResponse findUserStatusByUserId(UUID userId); - - List findUserStatuses(); - - UserStatusResponse updateUserStatus(UserStatusUpdateServiceRequest request); - - UserStatusResponse updateUserStatusByUserId(UUID userId); - - void deleteByUserId(UUID userId); -} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java index 4294132a0..9e358b5e6 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java @@ -1,42 +1,146 @@ package com.sprint.mission.discodeit.service.basic; -import com.sprint.mission.discodeit.dto.auth.request.SignIn; +import com.nimbusds.jose.JOSEException; +import com.sprint.mission.discodeit.constant.AuthErrorCode; +import com.sprint.mission.discodeit.constant.TokenErrorCode; +import com.sprint.mission.discodeit.constant.UserErrorCode; +import com.sprint.mission.discodeit.dto.auth.request.RoleUpdateRequest; import com.sprint.mission.discodeit.dto.user.UserResponse; +import com.sprint.mission.discodeit.entity.Role; import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.event.RoleUpdatedEvent; +import com.sprint.mission.discodeit.exception.TokenException; +import com.sprint.mission.discodeit.exception.UserAuthException; +import com.sprint.mission.discodeit.exception.UserException; +import com.sprint.mission.discodeit.mapper.UserMapper; import com.sprint.mission.discodeit.repository.UserRepository; -import com.sprint.mission.discodeit.repository.UserStatusRepository; +import com.sprint.mission.discodeit.security.DiscodeitUserDetails; +import com.sprint.mission.discodeit.security.jwt.JwtInformation; +import com.sprint.mission.discodeit.security.jwt.JwtProvider; +import com.sprint.mission.discodeit.security.jwt.JwtRegistry; import com.sprint.mission.discodeit.service.AuthService; +import java.util.Optional; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Qualifier; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor +@Transactional public class BasicAuthService implements AuthService { - @Qualifier("fileUserRepository") + @Value("${admin.username}") + private String adminName; + + @Value("${admin.email}") + private String adminEmail; + + @Value("${admin.password}") + private String adminPassword; + private final UserRepository userRepository; - @Qualifier("fileUserStatusRepository") - private final UserStatusRepository userStatusRepository; + private final PasswordEncoder passwordEncoder; + + private final UserDetailsService userDetailsService; + + private final JwtProvider jwtProvider; + + private final JwtRegistry jwtRegistry; + + private final UserMapper userMapper; + private final ApplicationEventPublisher applicationEventPublisher; @Override - public UserResponse login(SignIn signIn) { - User user = userRepository.findUserByEmail(signIn.getEmail()) - .orElseThrow(() -> new IllegalArgumentException("User not found.")); + @Transactional + @CacheEvict(value = "users", allEntries = true) + public UserResponse updateRole(RoleUpdateRequest roleUpdateRequest) { + User user = userRepository.findById(roleUpdateRequest.getUserId()) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); - if (!user.getPassword().equals(signIn.getPassword())) { - throw new IllegalArgumentException("Password is incorrect."); + Role oldRole = user.getRole(); + Role newRole = roleUpdateRequest.getNewRole(); + user.updateRole(newRole); + + jwtRegistry.invalidateJwtInformationByUserId(user.getId()); + userRepository.save(user); + + RoleUpdatedEvent roleUpdateEvent = new RoleUpdatedEvent(user.getId(), oldRole, newRole); + applicationEventPublisher.publishEvent(roleUpdateEvent); + return userMapper.toResponse(user); + + } + + @Transactional + public void registerAdmin() { + Optional existAdmin = userRepository.findByEmail(adminEmail); + + if(existAdmin.isPresent()) { + return; } - UserStatus userStatus = userStatusRepository.findUserStatusByUserId(user.getId()) - .orElseThrow(() -> new IllegalArgumentException("User status not found.")); + User newAdmin = User.builder() + .username(adminName) + .email(adminEmail) + .password(passwordEncoder.encode(adminPassword)) + .role(Role.ADMIN) + .build(); - userStatus.updateLastOnlineTime(); - userStatusRepository.save(userStatus); + userRepository.save(newAdmin); + } - return new UserResponse(user, userStatus); + @Override + public JwtInformation refreshToken(String refreshToken) { + if (refreshToken == null || refreshToken.isBlank()) { + log.error("Refresh token is missing or blank"); + throw new TokenException(TokenErrorCode.INVALID_REFRESH_TOKEN_FORMAT); + } + if (!jwtProvider.validateToken(refreshToken)) { + log.error("Invalid refresh token: {}", refreshToken); + throw new TokenException(TokenErrorCode.INVALID_REFRESH_TOKEN); + } + if(!jwtRegistry.hasActiveJwtInformationByRefreshToken(refreshToken)) { + log.error("Expired refresh token: {}", refreshToken); + throw new TokenException(TokenErrorCode.EXPIRED_REFRESH_TOKEN); + } + + String username = jwtProvider.getUsernameFromToken(refreshToken); + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + if (!(userDetails instanceof DiscodeitUserDetails discodeitUserDetails)) { + throw new UserAuthException(AuthErrorCode.INVALID_USER); + } + + try { + String newAccessToken = jwtProvider.createAccessToken(discodeitUserDetails); + String newRefreshToken = jwtProvider.createRefreshToken(discodeitUserDetails); + log.debug("Access token refreshed for user: {}", username); + + JwtInformation newJwtInformation = new JwtInformation( + discodeitUserDetails.getUserResponse(), + newAccessToken, + newRefreshToken + ); + jwtRegistry.rotateJwtInformation( + refreshToken, + newJwtInformation + ); + + return newJwtInformation; + + } catch (JOSEException e) { + log.error("Failed to generate new tokens for user: {}", username, e); + throw new TokenException(TokenErrorCode.TOKEN_CREATION_ERROR); + } } + } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java index a135daf1e..74c321683 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java @@ -1,48 +1,86 @@ package com.sprint.mission.discodeit.service.basic; +import com.sprint.mission.discodeit.constant.BinaryContentErrorCode; import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponse; import com.sprint.mission.discodeit.dto.binarycontent.request.BinaryContentCreateServiceRequest; -import com.sprint.mission.discodeit.entity.FileType; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.BinaryContentStatus; +import com.sprint.mission.discodeit.event.BinaryContentCreatedEvent; +import com.sprint.mission.discodeit.exception.BinaryContentException; +import com.sprint.mission.discodeit.mapper.BinaryContentMapper; import com.sprint.mission.discodeit.repository.BinaryContentRepository; import com.sprint.mission.discodeit.service.BinaryContentService; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Qualifier; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service +@Slf4j @RequiredArgsConstructor public class BasicBinaryContentService implements BinaryContentService { - @Qualifier("fileBinaryContentRepository") private final BinaryContentRepository binaryContentRepository; + private final BinaryContentMapper binaryContentMapper; + private final BinaryContentStorage binaryContentStorage; + + private final ApplicationEventPublisher applicationEventPublisher; @Override + @Transactional public void createBinaryContent(BinaryContentCreateServiceRequest request) { - FileType fileType = FileType.getFileTypeByCode(request.getFileType()); - binaryContentRepository.save(request.toEntity(fileType)); + log.info("binary content to create - fileName : {}", request.getFileName()); + BinaryContent binaryContent = request.toEntity(); + binaryContentRepository.save(binaryContent); + BinaryContentCreatedEvent binaryContentCreatedEvent = + new BinaryContentCreatedEvent(binaryContent.getId(), binaryContent.getBytes()); + applicationEventPublisher.publishEvent(binaryContentCreatedEvent); + log.info("binary content save sucessfully - id : {}", binaryContent.getId()); } @Override + @Transactional public void deleteById(UUID binaryContentId) { binaryContentRepository.deleteById(binaryContentId); } @Override + @Transactional(readOnly = true) public BinaryContentResponse findById(UUID binaryContentId) { - return binaryContentRepository.findBinaryContentById(binaryContentId) - .map(BinaryContentResponse::new) - .orElseThrow(() -> new IllegalArgumentException("BinaryContent not found")); + return binaryContentRepository.findById(binaryContentId) + .map(binaryContentMapper::toResponse) + .orElseThrow(() -> new BinaryContentException(BinaryContentErrorCode.BINARY_CONTENT_NOT_FOUND)); } @Override + @Transactional(readOnly = true) public List findAllByIdIn(List ids) { return ids.stream() - .map(id -> binaryContentRepository.findBinaryContentById(id).orElseThrow( - () -> new IllegalArgumentException("BinaryContent not found")) + .map(id -> binaryContentRepository.findById(id).orElseThrow( + () -> new BinaryContentException(BinaryContentErrorCode.BINARY_CONTENT_NOT_FOUND)) ) - .map(BinaryContentResponse::new) + .map(binaryContentMapper::toResponse) .toList(); } + + @Override + @Transactional + public BinaryContentResponse updateBinaryContentStatus(UUID binaryContentId, BinaryContentStatus binaryContentStatus) { + BinaryContent binaryContent = binaryContentRepository.findById(binaryContentId) + .orElseThrow(() -> new BinaryContentException(BinaryContentErrorCode.BINARY_CONTENT_NOT_FOUND)); + binaryContent.updateStatus(binaryContentStatus); + binaryContentRepository.save(binaryContent); + return binaryContentMapper.toResponse(binaryContent); + } + + @Override + public ResponseEntity download(BinaryContentResponse response) { + log.info("binary content to download - id : {}", response.getId()); + return binaryContentStorage.download(response); + } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java index 85c5c2047..3df360c83 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java @@ -1,16 +1,17 @@ package com.sprint.mission.discodeit.service.basic; +import com.sprint.mission.discodeit.constant.ChannelErrorCode; +import com.sprint.mission.discodeit.constant.UserErrorCode; import com.sprint.mission.discodeit.dto.channel.ChannelResponse; -import com.sprint.mission.discodeit.dto.channel.PrivateChannelResponse; -import com.sprint.mission.discodeit.dto.channel.PublicChannelResponse; import com.sprint.mission.discodeit.dto.channel.request.ChannelCreateServiceRequest; import com.sprint.mission.discodeit.dto.channel.request.ChannelUpdateServiceRequest; import com.sprint.mission.discodeit.dto.channel.request.PrivateChannelCreateServiceRequest; -import com.sprint.mission.discodeit.entity.ActiveStatus; import com.sprint.mission.discodeit.entity.Channel; import com.sprint.mission.discodeit.entity.ChannelType; import com.sprint.mission.discodeit.entity.ReadStatus; -import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.ChannelException; +import com.sprint.mission.discodeit.exception.UserException; +import com.sprint.mission.discodeit.mapper.ChannelMapper; import com.sprint.mission.discodeit.repository.ChannelRepository; import com.sprint.mission.discodeit.repository.MessageRepository; import com.sprint.mission.discodeit.repository.ReadStatusRepository; @@ -20,109 +21,110 @@ import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Qualifier; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service +@Slf4j @RequiredArgsConstructor public class BasicChannelService implements ChannelService { - @Qualifier("fileChannelRepository") private final ChannelRepository channelRepository; - @Qualifier("fileUserRepository") private final UserRepository userRepository; - @Qualifier("fileMessageRepository") private final MessageRepository messageRepository; - @Qualifier("fileReadStatusRepository") private final ReadStatusRepository readStatusRepository; + private final ChannelMapper channelMapper; + @Override + @Transactional + @CacheEvict(value = "channels", allEntries = true) public ChannelResponse createPublicChannel(ChannelCreateServiceRequest request) { - - ChannelType channelType = ChannelType.getChannelTypeByCode(request.getChannelTypeCode()); - Channel channel = request.toEntity(channelType); - - User hostUser = userRepository.findUserById(request.getHostId()).orElseThrow(() -> new IllegalArgumentException("User not found.")); - - if(hostUser.getActiveStatus() == ActiveStatus.ACTIVE) { - ReadStatus readStatus = new ReadStatus(hostUser.getId(), channel.getId()); - - hostUser.addReadStatus(readStatus); - channel.addUserReadStatus(readStatus); - - readStatusRepository.save(readStatus); - channelRepository.save(channel); - userRepository.save(hostUser); - } - return new PublicChannelResponse(channel); + log.info("public channel to create - name : {}, description : {}", request.getName(), request.getDescription()); + Channel channel = channelMapper.toEntity(request, ChannelType.PUBLIC); + channelRepository.save(channel); + log.info("public channel created successfully - id : {}", channel.getId()); + return channelMapper.toResponse(channel); } @Override + @Transactional + @CacheEvict(value = "channels", allEntries = true) public ChannelResponse createPrivateChannel(PrivateChannelCreateServiceRequest request) { + Channel channel = channelMapper.toEntity(request, ChannelType.PRIVATE); + channelRepository.save(channel); - ChannelType channelType = ChannelType.getChannelTypeByCode(request.getChannelTypeCode()); - Channel channel = request.toEntity(channelType); - - User hostUser = userRepository.findUserById(request.getHostId()).orElseThrow(() -> new IllegalArgumentException("User not found.")); - - if(hostUser.getActiveStatus() == ActiveStatus.ACTIVE) { - ReadStatus readStatus = new ReadStatus(hostUser.getId(), channel.getId()); - - hostUser.addReadStatus(readStatus); - channel.addUserReadStatus(readStatus); + List readStatuses = request.getParticipantIds().stream() + .map(userId -> userRepository.findById(userId) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND))) + .map(user -> new ReadStatus(user, channel)) + .toList(); - readStatusRepository.save(readStatus); - channelRepository.save(channel); - userRepository.save(hostUser); - } + readStatusRepository.saveAll(readStatuses); - return new PrivateChannelResponse(channel); + log.info("private channel created successfully - id : {}", channel.getId()); + return channelMapper.toResponse(channel); } @Override + @Transactional(readOnly = true) public ChannelResponse findChannelById(UUID channelId) { - return channelRepository.findChannelById(channelId) - .map(channel -> - channel.getChannelType().getCode().startsWith("CHANNEL-1") ? new PublicChannelResponse(channel) : new PrivateChannelResponse(channel) - ) - .orElseThrow(() -> new IllegalArgumentException("Channel not found.")); - + return channelRepository.findById(channelId) + .map(channelMapper::toResponse) + .orElseThrow(() -> new ChannelException(ChannelErrorCode.CHANNEL_NOT_FOUND)); } @Override + @Transactional(readOnly = true) + @Cacheable(value = "channels", key = "#userId") public List findAllChannelsByUserId(UUID userId) { - return channelRepository.findChannels() + List joinChannels = readStatusRepository.findAllByUserId(userId) .stream() - .filter(channel -> channel.getReadStatuses().stream().anyMatch(readStatus -> readStatus.getUserId().equals(userId))) - .map(channel -> - channel.getChannelType().getCode().startsWith("CHANNEL-1") ? new PublicChannelResponse(channel) : new PrivateChannelResponse(channel) - ) + .map(ReadStatus::getChannelId) + .toList(); + + return channelRepository.findAll() + .stream() + .filter(channel -> joinChannels.contains(channel.getId())|| channel.getType() == ChannelType.PUBLIC) + .map(channelMapper::toResponse) .toList(); } - //현재 privateChannel은 수정 불가 @Override + @Transactional + @CacheEvict(value = "channels", allEntries = true) public ChannelResponse updateChannel(ChannelUpdateServiceRequest request) { - Channel channelToUpdate = channelRepository.findChannelById(request.getChannelId()) - .orElseThrow(() -> new IllegalArgumentException("Channel not found.")); + Channel channelToUpdate = channelRepository.findById(request.getChannelId()) + .orElseThrow(() -> new ChannelException(ChannelErrorCode.CHANNEL_NOT_FOUND)); + + if(channelToUpdate.getType() == ChannelType.PRIVATE) { + throw new ChannelException(ChannelErrorCode.PRIVATE_CHANNEL_NOT_EDITABLE); + } - Optional.ofNullable(request.getChannelName()).ifPresent(channelToUpdate::editChannelName); + log.info("channel to update - name : {}, description : {}", request.getName(), request.getDescription()); + Optional.ofNullable(request.getName()).ifPresent(channelToUpdate::editChannelName); Optional.ofNullable(request.getDescription()).ifPresent(channelToUpdate::editDescription); channelRepository.save(channelToUpdate); - - return new PublicChannelResponse(channelToUpdate); + log.info("channel updated - name : {}, description : {}", channelToUpdate.getName(), channelToUpdate.getDescription()); + return channelMapper.toResponse(channelToUpdate); } @Override + @Transactional + @CacheEvict(value = "channels", allEntries = true) public void deleteChannel(UUID channelId) { messageRepository.deleteAllByChannelId(channelId); readStatusRepository.deleteAllByChannelId(channelId); - channelRepository.delete(channelId); + channelRepository.deleteById(channelId); + log.info("deleted channel - id : {}", channelId); } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java index 6ddcd86da..4d299f0fb 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java @@ -1,158 +1,193 @@ package com.sprint.mission.discodeit.service.basic; +import com.sprint.mission.discodeit.constant.BinaryContentErrorCode; +import com.sprint.mission.discodeit.constant.ChannelErrorCode; +import com.sprint.mission.discodeit.constant.MessageErrorCode; +import com.sprint.mission.discodeit.constant.UserErrorCode; +import com.sprint.mission.discodeit.dto.PageResponse; import com.sprint.mission.discodeit.dto.message.MessageResponse; import com.sprint.mission.discodeit.dto.message.request.MessageCreateServiceRequest; import com.sprint.mission.discodeit.dto.message.request.MessageUpdateServiceRequest; import com.sprint.mission.discodeit.entity.BinaryContent; import com.sprint.mission.discodeit.entity.Channel; import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.MessageAttachment; import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.event.BinaryContentCreatedEvent; +import com.sprint.mission.discodeit.event.MessageCreatedEvent; +import com.sprint.mission.discodeit.exception.BinaryContentException; +import com.sprint.mission.discodeit.exception.ChannelException; +import com.sprint.mission.discodeit.exception.MessageException; +import com.sprint.mission.discodeit.exception.UserException; +import com.sprint.mission.discodeit.mapper.MessageMapper; +import com.sprint.mission.discodeit.mapper.PageResponseMapper; import com.sprint.mission.discodeit.repository.BinaryContentRepository; import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageAttachmentRepository; import com.sprint.mission.discodeit.repository.MessageRepository; import com.sprint.mission.discodeit.repository.UserRepository; import com.sprint.mission.discodeit.service.MessageService; +import com.sprint.mission.discodeit.util.BinaryContentConverter; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Qualifier; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; -@Service +@Service("messageService") +@Slf4j @RequiredArgsConstructor public class BasicMessageService implements MessageService { - @Qualifier("fileMessageRepository") private final MessageRepository messageRepository; - - @Qualifier("fileChannelRepository") private final ChannelRepository channelRepository; - - @Qualifier("fileUserRepository") private final UserRepository userRepository; - - @Qualifier("fileBinaryContentRepository") private final BinaryContentRepository binaryContentRepository; + private final MessageAttachmentRepository messageAttachmentRepository; + + private final MessageMapper messageMapper; + private final PageResponseMapper pageResponseMapper; + + private final ApplicationEventPublisher applicationEventPublisher; @Override + @Transactional public MessageResponse createMessage(MessageCreateServiceRequest request) { - Message message = request.toEntity(); - Channel findChannel = channelRepository.findChannelById(request.getChannelId()) - .orElseThrow(() -> new IllegalArgumentException("Channel not found.")); + User author = userRepository.findById(request.getUserId()) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + + Channel findChannel = channelRepository.findById(request.getChannelId()) + .orElseThrow(() -> new ChannelException(ChannelErrorCode.CHANNEL_NOT_FOUND)); + + Message message = messageMapper.toEntity(request, author, findChannel); - User findUser = userRepository.findUserById(request.getUserId()) - .orElseThrow(() -> new IllegalArgumentException("User not found.")); + log.info("message to create - content={}", message.getContent()); - findChannel.addMessage(message); - findUser.addMessage(message); + List attachments = new ArrayList<>(); + + List multipartFiles = Optional.ofNullable(request.getAttachments()).orElse(new ArrayList<>()); + + List binaryContents = convertMultipartFilesToBinaryContents(multipartFiles); + + binaryContentRepository.saveAll(binaryContents); + + binaryContents.stream() + .forEach(binaryContent -> { + BinaryContentCreatedEvent binaryContentCreatedEvent = + new BinaryContentCreatedEvent(binaryContent.getId(), binaryContent.getBytes()); + applicationEventPublisher.publishEvent(binaryContentCreatedEvent); + }); + + attachments = convertBinaryContentsToMessageAttachment(binaryContents, message); + + message.addAttachments(attachments); - channelRepository.save(findChannel); - userRepository.save(findUser); messageRepository.save(message); - return new MessageResponse(message); + log.info("message created sucessfully - id={}", message.getId()); + + MessageCreatedEvent messageCreatedEvent = new MessageCreatedEvent(message.getId(), message.getAuthorId(), + message.getChannelId(), message.getContent()); + applicationEventPublisher.publishEvent(messageCreatedEvent); + + return messageMapper.toResponse(message); } - private void addBinaryContentsToMessage(List binaryContents) { - if(!binaryContents.isEmpty()) { - for (BinaryContent content : binaryContents) { - binaryContentRepository.save(content); - } + private BinaryContent getBinaryContentFromMultipartFile(MultipartFile profile) { + BinaryContent binaryContent; + try { + binaryContent = BinaryContentConverter.toBinaryContent(profile); + } catch(IOException e) { + throw new BinaryContentException(BinaryContentErrorCode.MULTIPART_FILE_CONVERT_FAILED); } + return binaryContent; + } + + private List convertMultipartFilesToBinaryContents(List multipartFiles) { + return multipartFiles.stream() + .map(this::getBinaryContentFromMultipartFile) + .toList(); + } + + private List convertBinaryContentsToMessageAttachment(List binaryContents, Message message) { + return binaryContents.stream() + .map(binaryContent -> + MessageAttachment.builder() + .binaryContent(binaryContent) + .message(message) + .build()) + .collect(Collectors.toList()); } @Override + @Transactional(readOnly = true) public MessageResponse findMessageById(UUID messageId) { - Message findMessage = messageRepository.findMessageById(messageId) - .orElseThrow(() -> new IllegalArgumentException("Message not found.")); + Message findMessage = messageRepository.findById(messageId) + .orElseThrow(() -> new MessageException(MessageErrorCode.MESSAGE_NOT_FOUND)); - return new MessageResponse(findMessage); + return messageMapper.toResponse(findMessage); } @Override - public List findMessagesByChannelId(UUID channelId) { - return messageRepository.findMessages() - .stream() - .filter(message -> message.getChannelId().equals(channelId)) - .map(MessageResponse::new) - .toList(); + @Transactional(readOnly = true) + public PageResponse findMessagesByChannelId(UUID channelId, Instant cursor, Pageable pageable) { + Slice messages = messageRepository.findChannelMessagesByCursor(channelId, Optional.ofNullable(cursor).orElse(Instant.now()), pageable) + .map(messageMapper::toResponse); + + Instant nextCursor = null; + + if(!messages.getContent().isEmpty()) { + nextCursor = messages.getContent().get(messages.getContent().size() - 1).getCreatedAt(); + } + + return pageResponseMapper.toPageResponse(messages, nextCursor); } @Override + @Transactional(readOnly = true) public List findMessagesByUserId(UUID userId) { - return messageRepository.findMessages() + return messageRepository.findAll() .stream() - .filter(message -> message.getUserId().equals(userId)) - .map(MessageResponse::new) + .filter(message -> message.getAuthorId().equals(userId)) + .map(messageMapper::toResponse) .toList(); } @Override + @Transactional public MessageResponse updateContent(MessageUpdateServiceRequest request) { - Optional.ofNullable(request.getContent()).orElseThrow(() -> new IllegalArgumentException("Content is null.")); - - Message messageToUpdate = messageRepository.findMessageById(request.getMessageId()) - .orElseThrow(() -> new IllegalArgumentException("Message not found.")); - - User author = userRepository.findUserById(request.getUserId()) - .orElseThrow(() -> new IllegalArgumentException("User not found.")); + Optional.ofNullable(request.getContent()).orElseThrow(() -> new MessageException(MessageErrorCode.UPDATE_CONTENT_IS_NULL)); - Channel channel = channelRepository.findChannelById(request.getChannelId()) - .orElseThrow(() -> new IllegalArgumentException("Channel not found.")); - - - editUserMessageContent(author, request); - editChannelMessageContent(channel, request); + Message messageToUpdate = messageRepository.findById(request.getMessageId()) + .orElseThrow(() -> new MessageException(MessageErrorCode.MESSAGE_NOT_FOUND)); + log.info("message to update - content={}", messageToUpdate.getContent()); messageToUpdate.editContent(request.getContent()); - channelRepository.save(channel); - userRepository.save(author); messageRepository.save(messageToUpdate); - return new MessageResponse(messageToUpdate); - } - - private void editUserMessageContent(User author, MessageUpdateServiceRequest request) { - author.getMessages().stream() - .filter(myMessage -> myMessage.getId().equals(request.getMessageId())) - .findFirst() - .ifPresentOrElse(myMessage -> myMessage.editContent(request.getContent()), - () -> {throw new IllegalArgumentException("Message not found.");} - ); - } - - public void editChannelMessageContent(Channel channel, MessageUpdateServiceRequest request) { - channel.getMessages().stream() - .filter(channelMessage -> channelMessage.getId().equals(request.getMessageId())) - .findFirst() - .ifPresentOrElse(channelMessage -> channelMessage.editContent(request.getContent()), - () -> {throw new IllegalArgumentException("Message not found.");} - ); + log.info("message updated - content={}", messageToUpdate.getContent()); + return messageMapper.toResponse(messageToUpdate); } @Override + @Transactional public void deleteMessage(UUID messageId) { + messageRepository.findById(messageId) + .orElseThrow(() -> new MessageException(MessageErrorCode.MESSAGE_NOT_FOUND)); - Message findMessage = messageRepository.findMessageById(messageId) - .orElseThrow(() -> new IllegalArgumentException("Message not found.")); - - User author = userRepository.findUserById(findMessage.getUserId()) - .orElseThrow(() -> new IllegalArgumentException("User not found.")); - - Channel channel = channelRepository.findChannelById(findMessage.getChannelId()) - .orElseThrow(() -> new IllegalArgumentException("Channel not found.")); - - if(findMessage.getBinaryContents() != null && !findMessage.getBinaryContents().isEmpty()) { - List binaryContent = binaryContentRepository.findBinaryContentsByMessageId(messageId); - removeBinaryContentsOfMessage(binaryContent); - } - - removeMessageFromUser(author, messageId); - removeMessageFromChannel(channel, messageId); removeMessage(messageId); - + log.info("message deleted - id={}", messageId); } private void removeBinaryContentsOfMessage(List binaryContents) { @@ -163,31 +198,17 @@ private void removeBinaryContentsOfMessage(List binaryContents) { } } - private void removeMessageFromUser(User author, UUID messageId) { - author.getMessages().stream() - .filter(myMessage -> myMessage.getId().equals(messageId)) - .findFirst() - .ifPresentOrElse( - author::removeMessage, - () -> { throw new IllegalArgumentException("Message not found."); } - ); - } - - private void removeMessageFromChannel(Channel channel, UUID messageId) { - channel.getMessages().stream() - .filter(channelMessage -> channelMessage.getId().equals(messageId)) - .findFirst() + private void removeMessage(UUID messageId) { + messageRepository.findById(messageId) .ifPresentOrElse( - channel::removeMessage, - () -> { throw new IllegalArgumentException("Message not found."); } + message -> messageRepository.deleteById(messageId), + () -> { throw new MessageException(MessageErrorCode.MESSAGE_NOT_FOUND); } ); } - private void removeMessage(UUID messageId) { - messageRepository.findMessageById(messageId) - .ifPresentOrElse( - message -> messageRepository.delete(messageId), - () -> { throw new IllegalArgumentException("Message not found."); } - ); + public boolean isAuthor(UUID authorId, UUID messageId) { + return messageRepository.findById(messageId) + .map(message -> message.getAuthorId().equals(authorId)) + .orElseThrow(() -> new MessageException(MessageErrorCode.MESSAGE_NOT_FOUND)); } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicNotificationService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicNotificationService.java new file mode 100644 index 000000000..8c8c2a0b5 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicNotificationService.java @@ -0,0 +1,54 @@ +package com.sprint.mission.discodeit.service.basic; + +import com.sprint.mission.discodeit.constant.NotificationErrorCode; +import com.sprint.mission.discodeit.dto.notification.NotificationDto; +import com.sprint.mission.discodeit.entity.Notification; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.NotificationException; +import com.sprint.mission.discodeit.mapper.NotificationMapper; +import com.sprint.mission.discodeit.repository.NotificationRepository; +import com.sprint.mission.discodeit.service.NotificationService; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class BasicNotificationService implements NotificationService { + + private final NotificationRepository notificationRepository; + + private final NotificationMapper notificationMapper; + + @Transactional + @CacheEvict(value = "notifications", key = "#receiver.id") + public void create(User receiver, String title, String content) { + Notification notification = new Notification(receiver, title, content); + notificationRepository.save(notification); + } + + @Transactional + @Cacheable(value = "notifications", key = "#receiverId") + public List getNotifications(UUID receiverId) { + List notifications = notificationRepository.findAllByReceiverId(receiverId); + + return notifications.stream() + .map(notificationMapper::toDto) + .toList(); + } + + @Transactional + @CacheEvict(value = "notifications", allEntries = true) + public void deleteNotification(UUID notificationId, UUID userId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new NotificationException(NotificationErrorCode.NOTIFICATION_NOT_FOUND)); + + if(!notification.getReceiverId().equals(userId)) { + throw new NotificationException(NotificationErrorCode.NOT_AUTHORIZED); + } + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java index af7a45c24..5b7a00ccf 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java @@ -1,137 +1,116 @@ package com.sprint.mission.discodeit.service.basic; +import com.sprint.mission.discodeit.constant.ChannelErrorCode; +import com.sprint.mission.discodeit.constant.ReadStatusErrorCode; +import com.sprint.mission.discodeit.constant.UserErrorCode; import com.sprint.mission.discodeit.dto.readstatus.ReadStatusResponse; import com.sprint.mission.discodeit.dto.readstatus.request.ReadStatusCreateServiceRequest; import com.sprint.mission.discodeit.dto.readstatus.request.ReadStatusUpdateServiceRequest; import com.sprint.mission.discodeit.entity.Channel; import com.sprint.mission.discodeit.entity.ReadStatus; import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.ChannelException; +import com.sprint.mission.discodeit.exception.ReadStatusException; +import com.sprint.mission.discodeit.exception.UserException; +import com.sprint.mission.discodeit.mapper.ReadStatusMapper; import com.sprint.mission.discodeit.repository.ChannelRepository; import com.sprint.mission.discodeit.repository.ReadStatusRepository; import com.sprint.mission.discodeit.repository.UserRepository; import com.sprint.mission.discodeit.service.ReadStatusService; import java.util.List; +import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class BasicReadStatusService implements ReadStatusService { - @Qualifier("fileReadStatusRepository") private final ReadStatusRepository readStatusRepository; - @Qualifier("fileChannelRepository") private final ChannelRepository channelRepository; - @Qualifier("fileUserRepository") private final UserRepository userRepository; + private final ReadStatusMapper readStatusMapper; + @Override + @Transactional public ReadStatusResponse createReadStatus(ReadStatusCreateServiceRequest request) { - Channel channel = channelRepository.findChannelById(request.getChannelId()) - .orElseThrow(() -> new IllegalArgumentException("Channel not found.")); + Channel channel = channelRepository.findById(request.getChannelId()) + .orElseThrow(() -> new ChannelException(ChannelErrorCode.CHANNEL_NOT_FOUND)); - User user = userRepository.findUserById(request.getUserId()) - .orElseThrow(() -> new IllegalArgumentException("User not found.")); + User user = userRepository.findById(request.getUserId()) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); - ReadStatus readStatus = request.toEntity(); + readStatusRepository.findReadStatusByUserIdAndChannelId(request.getUserId(), request.getChannelId()) + .ifPresent(readStatus -> {throw new ReadStatusException(ReadStatusErrorCode.READ_STATUS_ALREADY_EXIST);}); - channel.addUserReadStatus(readStatus); - user.addReadStatus(readStatus); + ReadStatus readStatus = readStatusMapper.toEntity(request, user, channel); - userRepository.save(user); - channelRepository.save(channel); readStatusRepository.save(readStatus); - return new ReadStatusResponse(readStatus); + return readStatusMapper.toResponse(readStatus); } @Override + @Transactional(readOnly = true) public ReadStatusResponse findReadStatusById(UUID userId) { - return readStatusRepository.findReadStatusById(userId) - .map(ReadStatusResponse::new) - .orElseThrow(() -> new IllegalArgumentException("ReadStatus not found.")); + return readStatusRepository.findById(userId) + .map(readStatusMapper::toResponse) + .orElseThrow(() -> new ReadStatusException(ReadStatusErrorCode.READ_STATUS_NOT_FOUND)); } @Override + @Transactional(readOnly = true) public ReadStatusResponse findReadStatusByUserIdAndChannelId(UUID userId, UUID channelId) { return readStatusRepository.findReadStatusByUserIdAndChannelId(userId, channelId) - .map(ReadStatusResponse::new) - .orElseThrow(() -> new IllegalArgumentException("ReadStatus not found.")); + .map(readStatusMapper::toResponse) + .orElseThrow(() -> new ReadStatusException(ReadStatusErrorCode.READ_STATUS_NOT_FOUND)); } @Override + @Transactional(readOnly = true) public List findAllByUserId(UUID userId) { return readStatusRepository.findAllByUserId(userId) .stream() - .map(ReadStatusResponse::new) + .map(readStatusMapper::toResponse) .toList(); } @Override + @Transactional public ReadStatusResponse updateReadStatus(ReadStatusUpdateServiceRequest request) { - ReadStatus readStatusToUpdate = readStatusRepository.findReadStatusById(request.getReadStatusId()) - .orElseThrow(() -> new IllegalArgumentException("ReadStatus not found.")); - - Channel channel = channelRepository.findChannelById(request.getChannelId()) - .orElseThrow(() -> new IllegalArgumentException("Channel not found.")); - - User user = userRepository.findUserById(request.getUserId()) - .orElseThrow(() -> new IllegalArgumentException("User not found.")); + ReadStatus readStatusToUpdate = readStatusRepository.findById(request.getReadStatusId()) + .orElseThrow(() -> new ReadStatusException(ReadStatusErrorCode.READ_STATUS_NOT_FOUND)); readStatusToUpdate.updateLastReadAt(); - - user.addReadStatus(readStatusToUpdate); - channel.addUserReadStatus(readStatusToUpdate); + Optional.ofNullable(request.getNewNotificationEnabled()).ifPresent(readStatusToUpdate::updateNotificationEnabled); readStatusRepository.save(readStatusToUpdate); - userRepository.save(user); - channelRepository.save(channel); - return new ReadStatusResponse(readStatusToUpdate); + return readStatusMapper.toResponse(readStatusToUpdate); } @Override + @Transactional public void deleteReadStatus(UUID readStatusId) { - ReadStatus readStatus = readStatusRepository.findReadStatusById(readStatusId) - .orElseThrow(() -> new IllegalArgumentException("ReadStatus not found.")); - - Channel channel = channelRepository.findChannelById(readStatus.getChannelId()) - .orElseThrow(() -> new IllegalArgumentException("Channel not found.")); + ReadStatus readStatus = readStatusRepository.findById(readStatusId) + .orElseThrow(() -> new ReadStatusException(ReadStatusErrorCode.READ_STATUS_NOT_FOUND)); - User user = userRepository.findUserById(readStatus.getUserId()) - .orElseThrow(() -> new IllegalArgumentException("User not found.")); + Channel channel = channelRepository.findById(readStatus.getChannelId()) + .orElseThrow(() -> new ChannelException(ChannelErrorCode.CHANNEL_NOT_FOUND)); - removeReadStatusesFromChannel(channel, readStatusId); - removeReadStatusesFromUser(user, readStatusId); + User user = userRepository.findById(readStatus.getUserId()) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); channelRepository.save(channel); userRepository.save(user); readStatusRepository.deleteById(readStatusId); } - private void removeReadStatusesFromChannel(Channel channel, UUID readStatusId) { - channel.getReadStatuses().stream() - .filter(rs -> rs.getId().equals(readStatusId)) - .findFirst() - .ifPresentOrElse( - channel::removeUserReadStatus, - () -> {throw new IllegalArgumentException("ReadStatus not found.");} - ); - } - - private void removeReadStatusesFromUser(User user, UUID readStatusId) { - user.getReadStatuses().stream() - .filter(rs -> rs.getId().equals(readStatusId)) - .findFirst() - .ifPresentOrElse( - user::removeReadStatus, - () -> {throw new IllegalArgumentException("ReadStatus not found.");} - ); - } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java index 406b8cb0a..97f7da9f4 100644 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java +++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java @@ -1,15 +1,20 @@ package com.sprint.mission.discodeit.service.basic; +import com.sprint.mission.discodeit.constant.BinaryContentErrorCode; +import com.sprint.mission.discodeit.constant.UserErrorCode; import com.sprint.mission.discodeit.dto.user.UserResponse; import com.sprint.mission.discodeit.dto.user.request.UserCreateServiceRequest; import com.sprint.mission.discodeit.dto.user.request.UserUpdateServiceRequest; -import com.sprint.mission.discodeit.entity.ActiveStatus; import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.Role; import com.sprint.mission.discodeit.entity.User; -import com.sprint.mission.discodeit.entity.UserStatus; +import com.sprint.mission.discodeit.event.BinaryContentCreatedEvent; +import com.sprint.mission.discodeit.exception.BinaryContentException; +import com.sprint.mission.discodeit.exception.UserException; +import com.sprint.mission.discodeit.mapper.UserMapper; import com.sprint.mission.discodeit.repository.BinaryContentRepository; import com.sprint.mission.discodeit.repository.UserRepository; -import com.sprint.mission.discodeit.repository.UserStatusRepository; +import com.sprint.mission.discodeit.security.jwt.JwtRegistry; import com.sprint.mission.discodeit.service.UserService; import com.sprint.mission.discodeit.util.BinaryContentConverter; import java.io.IOException; @@ -17,173 +22,152 @@ import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Qualifier; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +@Slf4j @Service @RequiredArgsConstructor public class BasicUserService implements UserService { - @Qualifier("fileUserRepository") private final UserRepository userRepository; - @Qualifier("fileUserStatusRepository") - private final UserStatusRepository userStatusRepository; - - @Qualifier("fileBinaryContentRepository") private final BinaryContentRepository binaryContentRepository; + private final UserMapper userMapper; + + private final PasswordEncoder passwordEncoder; + + private final JwtRegistry jwtRegistry; + + private final ApplicationEventPublisher applicationEventPublisher; + @Override + @Transactional + @CacheEvict(value = "users", allEntries = true) public UserResponse createUser(UserCreateServiceRequest request) { + log.info("User attempting registeration - username : {}, userEmail : {}", request.getUsername(), request.getEmail()); validateEmailDoesNotExist(request.getEmail()); - validateUserDoesNotExist(request.getUserName()); + validateUserDoesNotExist(request.getUsername()); MultipartFile profile = request.getProfile(); - User newUser = request.toEntity(); + User newUser = userMapper.toEntity(request); BinaryContent binaryProfile; if(profile != null) { - binaryProfile = getBinaryContent(newUser, profile); + binaryProfile = getBinaryContent(profile); newUser.updateProfile(binaryProfile); binaryContentRepository.save(binaryProfile); + BinaryContentCreatedEvent binaryContentCreatedEvent = + new BinaryContentCreatedEvent(binaryProfile.getId(), binaryProfile.getBytes()); + applicationEventPublisher.publishEvent(binaryContentCreatedEvent); } - UserStatus newUserStatus = new UserStatus(newUser.getId()); - userStatusRepository.save(newUserStatus); + String password = request.getPassword(); + String hashedPassword = passwordEncoder.encode(password); + newUser.updatePassword(hashedPassword); + + newUser.updateRole(Role.USER); + userRepository.save(newUser); - return new UserResponse(newUser, newUserStatus); + + log.info("user created successfully - userId : {}", newUser.getId()); + UserResponse userResponse = userMapper.toResponse(newUser); + return userResponse; } private void validateEmailDoesNotExist(String email) { - userRepository.findUserByEmail(email) + userRepository.findByEmail(email) .ifPresent(user -> { - throw new IllegalArgumentException("Email is duplicated."); + throw new UserException(UserErrorCode.EMAIL_DUPLICATED); }); + log.debug("email is not duplicated : {}", email); } private void validateUserDoesNotExist(String userName) { - userRepository.findUserByUserName(userName) + userRepository.findByUsername(userName) .ifPresent(user -> { - throw new IllegalArgumentException("User name is duplicated."); + throw new UserException(UserErrorCode.USER_NAME_DUPLICATED); }); + log.debug("username is not duplicated : {}", userName); } - @Override - public UserResponse findUserById(UUID userId) { - User findUser = userRepository.findUserById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found.")); - - UserStatus findUserStatus = userStatusRepository.findUserStatusByUserId(userId) - .orElseThrow(() -> new IllegalArgumentException("User status not found.")); - - - return new UserResponse(findUser, findUserStatus); - } - - @Override - public UserResponse findDormantUserById(UUID userId) { - User findDormantUser = userRepository.findUsers() - .stream() - .filter(user -> user.getActiveStatus() == ActiveStatus.DORMANT) - .filter(user -> user.getId().equals(userId)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("User not found.")); - - UserStatus findUserStatus = userStatusRepository.findUserStatusById(userId) - .orElseThrow(() -> new IllegalArgumentException("User status not found.")); - - - return new UserResponse(findDormantUser, findUserStatus); + private static BinaryContent getBinaryContent(MultipartFile profile) { + BinaryContent binaryProfile; + try { + binaryProfile = BinaryContentConverter.toBinaryContent(profile); + } catch(IOException e) { + throw new BinaryContentException(BinaryContentErrorCode.MULTIPART_FILE_CONVERT_FAILED); + } + return binaryProfile; } @Override - public UserResponse findDeletedUserById(UUID userId) { - User findDeletedUser = userRepository.findUsers() - .stream() - .filter(user -> user.getActiveStatus() == ActiveStatus.DELETED) - .filter(user -> user.getId().equals(userId)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("User not found.")); - - UserStatus findUserStatus = userStatusRepository.findUserStatusById(userId) - .orElseThrow(() -> new IllegalArgumentException("User status not found.")); - + @Transactional(readOnly = true) + public UserResponse findUserById(UUID userId) { + User findUser = userRepository.findById(userId) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); - return new UserResponse(findDeletedUser, findUserStatus); + return userMapper.toResponse(findUser); } @Override + @Transactional(readOnly = true) + @Cacheable(value = "users", key = "'users:all'") public List findUsers() { - return userRepository.findUsers() - .stream() - .map(user -> - new UserResponse(user, userStatusRepository.findUserStatusByUserId(user.getId()) - .orElseThrow(() -> new IllegalArgumentException("User status not found."))) - ) - .toList(); - } - @Override - public List findDormantUsers() { - return userRepository.findUsers() + return userRepository.findAll() .stream() - .filter(user -> user.getActiveStatus() == ActiveStatus.DORMANT) - .map(user -> - new UserResponse(user, userStatusRepository.findUserStatusById(user.getId()) - .orElseThrow(() -> new IllegalArgumentException("User status not found."))) - ) - .toList(); - } - - @Override - public List findDeletedUsers() { - return userRepository.findUsers() - .stream() - .filter(user -> user.getActiveStatus() == ActiveStatus.DELETED) - .map(user -> - new UserResponse(user, userStatusRepository.findUserStatusById(user.getId()) - .orElseThrow(() -> new IllegalArgumentException("User status not found."))) - ) + .map(user -> { + UserResponse userResponse = userMapper.toResponse(user); + if(jwtRegistry.hasActiveJwtInformationByUserId(user.getId())) { + userResponse.updateOnline(true); + } + return userResponse; + }) .toList(); } @Override + @Transactional + @CacheEvict(value = "users", allEntries = true) public UserResponse updateUser(UserUpdateServiceRequest request) { + User userToUpdate = userRepository.findById(request.getUserId()) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); - User userToUpdate = userRepository.findUserById(request.getUserId()) - .orElseThrow(() -> new IllegalArgumentException("User not found.")); + log.info("User before update - userId : {}, username : {}, userEmail : {}", userToUpdate.getId(), userToUpdate.getUsername(), userToUpdate.getEmail()); - Optional.ofNullable(request.getUserName()).ifPresent(userToUpdate::updateUserName); - Optional.ofNullable(request.getEmail()).ifPresent(userToUpdate::updateEmail); - Optional.ofNullable(request.getPhoneNumber()).ifPresent(userToUpdate::updatePhoneNumber); - Optional.ofNullable(request.getPassword()).ifPresent(userToUpdate::updatePassword); - Optional.ofNullable(request.getProfile()).ifPresent(binaryContent -> { - BinaryContent updateBinaryContent = getBinaryContent(userToUpdate, binaryContent); + Optional.ofNullable(request.getNewUsername()).ifPresent(userToUpdate::updateUserName); + Optional.ofNullable(request.getNewEmail()).ifPresent(userToUpdate::updateEmail); + Optional.ofNullable(request.getNewPassword()).ifPresent(newPassword -> userToUpdate.updatePassword(passwordEncoder.encode(newPassword))); + Optional.ofNullable(request.getProfile()).ifPresentOrElse(binaryContent -> { + BinaryContent updateBinaryContent = getBinaryContent(binaryContent); userToUpdate.updateProfile(updateBinaryContent); + userRepository.save(userToUpdate); binaryContentRepository.save(updateBinaryContent); - }); - - userRepository.save(userToUpdate); - - return new UserResponse(userToUpdate, userStatusRepository.findUserStatusByUserId(request.getUserId()) - .orElseThrow(() -> new IllegalArgumentException("User status not found."))); - } - - private static BinaryContent getBinaryContent(User newUser, MultipartFile profile) { - BinaryContent binaryProfile; - try { - binaryProfile = BinaryContentConverter.toBinaryContent(newUser.getId(), profile); - } catch(IOException e) { - throw new IllegalArgumentException("Failed to upload profile."); - } - return binaryProfile; + BinaryContentCreatedEvent binaryContentCreatedEvent = new BinaryContentCreatedEvent( + updateBinaryContent.getId(), + updateBinaryContent.getBytes() + ); + applicationEventPublisher.publishEvent(binaryContentCreatedEvent); + }, () -> userRepository.save(userToUpdate)); + + log.info("User after update - userId : {}, username : {}, userEmail : {}", userToUpdate.getId(), userToUpdate.getUsername(), userToUpdate.getEmail()); + return userMapper.toResponse(userToUpdate); } @Override + @Transactional + @CacheEvict(value = "users", allEntries = true) public void deleteUser(UUID userId) { - userStatusRepository.delete(userId); - userRepository.delete(userId); - binaryContentRepository.deleteByUserId(userId); + userRepository.deleteById(userId); + jwtRegistry.invalidateJwtInformationByUserId(userId); + log.info("deleted user - userId : {}", userId); } } diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java deleted file mode 100644 index ae9d6f04c..000000000 --- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.sprint.mission.discodeit.service.basic; - -import com.sprint.mission.discodeit.dto.userstatus.UserStatusResponse; -import com.sprint.mission.discodeit.dto.userstatus.request.UserStatusCreateServiceRequest; -import com.sprint.mission.discodeit.dto.userstatus.request.UserStatusUpdateServiceRequest; -import com.sprint.mission.discodeit.entity.UserStatus; -import com.sprint.mission.discodeit.repository.UserRepository; -import com.sprint.mission.discodeit.repository.UserStatusRepository; -import com.sprint.mission.discodeit.service.UserStatusService; -import java.util.List; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class BasicUserStatusService implements UserStatusService { - - @Qualifier("fileUserStatusRepository") - private final UserStatusRepository userStatusRepository; - - @Qualifier("fileUserRepository") - private final UserRepository userRepository; - - @Override - public void createUserStatus(UserStatusCreateServiceRequest request) { - userRepository.findUserById(request.getUserId()) - .orElseThrow(() -> new IllegalArgumentException("User not found.")); - - if(userStatusRepository.findUserStatusById(request.getUserId()).isPresent()){ - throw new IllegalArgumentException("UserStatus already exists."); - } - - userStatusRepository.save(request.toEntity()); - } - - @Override - public UserStatusResponse findUserStatusByUserId(UUID userId) { - return userStatusRepository.findUserStatuses().stream() - .filter(userStatus -> userStatus.getUserId().equals(userId)) - .findFirst() - .map(UserStatusResponse::new) - .orElseThrow(() -> new IllegalArgumentException("UserStatus not found.")); - } - - @Override - public List findUserStatuses() { - return userStatusRepository.findUserStatuses() - .stream() - .map(UserStatusResponse::new) - .toList(); - } - - @Override - public UserStatusResponse updateUserStatus(UserStatusUpdateServiceRequest request) { - UserStatus userStatusToUpdate = userStatusRepository.findUserStatusByUserId(request.getUserId()) - .orElseThrow(() -> new IllegalArgumentException("UserStatus not found.")); - - userStatusRepository.delete(request.getUserId()); - // 추후에 수정해야할 필드 추가되면 수정로직 추가 - - userStatusToUpdate.updateLastOnlineTime(); - - userStatusRepository.save(userStatusToUpdate); - - return new UserStatusResponse(userStatusToUpdate); - - } - - @Override - public UserStatusResponse updateUserStatusByUserId(UUID userId) { - UserStatus userStatusToUpdate = userStatusRepository.findUserStatusById(userId) - .orElseThrow(() -> new IllegalArgumentException("UserStatus not found.")); - - userStatusRepository.delete(userId); - // 추후에 수정해야할 필드 추가되면 수정로직 추가 - - userStatusToUpdate.updateLastOnlineTime(); - - userStatusRepository.save(userStatusToUpdate); - - return new UserStatusResponse(userStatusToUpdate); - } - - @Override - public void deleteByUserId(UUID userId) { - userStatusRepository.findUserStatusById(userId) - .ifPresentOrElse( - userStatus -> userStatusRepository.delete(userStatus.getId()), - () -> { throw new IllegalArgumentException("UserStatus not found."); } - ); - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java new file mode 100644 index 000000000..bfa83259e --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/storage/BinaryContentStorage.java @@ -0,0 +1,14 @@ +package com.sprint.mission.discodeit.storage; + +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponse; +import java.io.InputStream; +import java.util.UUID; +import org.springframework.http.ResponseEntity; + +public interface BinaryContentStorage { + UUID put (UUID binaryContentId, byte[] bytes); + + InputStream get(UUID binaryContentId); + + ResponseEntity download(BinaryContentResponse response); +} diff --git a/src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java new file mode 100644 index 000000000..932933d07 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/storage/local/LocalBinaryContentStorage.java @@ -0,0 +1,90 @@ +package com.sprint.mission.discodeit.storage.local; + +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponse; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "local") +public class LocalBinaryContentStorage implements BinaryContentStorage { + + private final Path path; + + public LocalBinaryContentStorage(@Value("${discodeit.storage.local.root-path}") String path) { + this.path = Paths.get(path); + } + + @PostConstruct + private void init() { + if (!Files.exists(path)) { + try { + Files.createDirectories(path); + } catch (IOException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + } + + + @Override + public UUID put(UUID binaryContentId, byte[] bytes) { + Path filePath = resolvePath(binaryContentId); + if(Files.exists(filePath)) { + throw new RuntimeException("Binary content already exists"); + } + try ( + OutputStream outputStream = Files.newOutputStream(filePath); + ) { + Thread.sleep(3000); + outputStream.write(bytes); + } catch (IOException e) { + throw new RuntimeException(e); + }catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Thread interrupted while simulating delay", e); + } + return binaryContentId; + } + + public Path resolvePath(UUID binaryContentId) { + return path.resolve(binaryContentId.toString()); + } + + @Override + public InputStream get(UUID binaryContentId) { + Path filePath = resolvePath(binaryContentId); + try { + return Files.newInputStream(filePath); + } catch (IOException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + + @Override + public ResponseEntity download(BinaryContentResponse response) { + InputStream inputStream = get(response.getId()); + Resource resource = new InputStreamResource(inputStream); + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + response.getFileName() + "\"") + .header(HttpHeaders.CONTENT_TYPE, response.getContentType()) + .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(response.getSize())) + .body(resource); + } +} diff --git a/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java new file mode 100644 index 000000000..be25825ce --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java @@ -0,0 +1,162 @@ +package com.sprint.mission.discodeit.storage.s3; + +import com.sprint.mission.discodeit.constant.BinaryContentErrorCode; +import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponse; +import com.sprint.mission.discodeit.event.BinaryContentUploadFailureEvent; +import com.sprint.mission.discodeit.exception.BinaryContentException; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.io.InputStream; +import java.time.Duration; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; + +@Slf4j +@Component +@ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "s3") +public class S3BinaryContentStorage implements BinaryContentStorage { + + private final String accessKey; + + private final String secretKey; + + private final String region; + + private final String bucket; + + private final ApplicationEventPublisher applicationEventPublisher; + + @Value("${discodeit.storage.s3.presigned-url-expiration}") + private long presignedUrlexpiration; + + public S3BinaryContentStorage( + @Value("${discodeit.storage.s3.access-key}") String accessKey, + @Value("${discodeit.storage.s3.secret-key}") String secretKey, + @Value("${discodeit.storage.s3.region}") String region, + @Value("${discodeit.storage.s3.bucket}") String bucket, + ApplicationEventPublisher applicationEventPublisher) { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.region = region; + this.bucket = bucket; + this.applicationEventPublisher = applicationEventPublisher; + } + + @Retryable(retryFor = {RuntimeException.class}, maxAttempts = 3, backoff=@Backoff(delay = 5000)) + @Override + public UUID put(UUID binaryContentId, byte[] bytes) { + S3Client s3Client = getS3Client(); + String key = binaryContentId.toString(); + + try { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + s3Client.putObject(request, RequestBody.fromBytes(bytes)); + log.info("파일 업로드 성공"); + return binaryContentId; + } catch (S3Exception e) { + log.error("파일 업로드 실패"); + throw new RuntimeException(e); + } + } + + @Recover + public void recover(RuntimeException e, UUID binaryContentId, byte[] content) { + String requestId = MDC.get("request_id"); + String errorMessage = e.getMessage(); + + applicationEventPublisher.publishEvent(new BinaryContentUploadFailureEvent(requestId, binaryContentId, errorMessage)); + + throw new BinaryContentException(BinaryContentErrorCode.UPLOAD_FAILED); + } + + @Override + public InputStream get(UUID binaryContentId) { + try { + S3Client s3Client = getS3Client(); + + GetObjectRequest request = GetObjectRequest.builder() + .bucket(bucket) + .key(binaryContentId.toString()) + .build(); +// byte[] bytes = s3Client.getObjectAsBytes(request).asByteArray(); +// return new ByteArrayInputStream(bytes); + return s3Client.getObject(request); + } catch (S3Exception e) { + log.error("파일 다운로드 실패 : {}", e.getMessage()); + } + return null; + } + + @Override + public ResponseEntity download(BinaryContentResponse response) { + String presignedUrl = generatePresignedUrl(response.getId().toString(), response.getContentType()); + + return ResponseEntity.status(HttpStatus.FOUND) + .header("Location", presignedUrl) + .build(); + } + + public S3Client getS3Client() { + return S3Client.builder() + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create( + accessKey, secretKey + ) + )) + .region(Region.of(region)) + .build(); + } + + public String generatePresignedUrl(String key, String contentType) { + S3Presigner s3Presigner = S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create( + accessKey, + secretKey + ) + ) + ) + .build(); + + + GetObjectRequest request = GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + + GetObjectPresignRequest presignedRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(15)) + .getObjectRequest(request) + .build(); + + PresignedGetObjectRequest presignedUrl = s3Presigner.presignGetObject(presignedRequest); + return presignedUrl.url().toString(); + } + +} diff --git a/src/main/java/com/sprint/mission/discodeit/util/BinaryContentConverter.java b/src/main/java/com/sprint/mission/discodeit/util/BinaryContentConverter.java index 834186596..46fae5d5f 100644 --- a/src/main/java/com/sprint/mission/discodeit/util/BinaryContentConverter.java +++ b/src/main/java/com/sprint/mission/discodeit/util/BinaryContentConverter.java @@ -1,18 +1,16 @@ package com.sprint.mission.discodeit.util; import com.sprint.mission.discodeit.entity.BinaryContent; -import com.sprint.mission.discodeit.entity.FileType; import java.io.IOException; -import java.util.UUID; import org.springframework.web.multipart.MultipartFile; public class BinaryContentConverter { - public static BinaryContent toBinaryContent(UUID userId, MultipartFile file) throws IOException { + public static BinaryContent toBinaryContent(MultipartFile file) throws IOException { return BinaryContent.builder() - .userId(userId) .fileName(file.getOriginalFilename()) - .fileType(FileType.getFileTypeByExtension(file.getContentType())) - .data(file.getBytes()) + .contentType(file.getContentType()) + .bytes(file.getBytes()) + .size(file.getSize()) .build(); } } diff --git a/src/main/java/com/sprint/mission/discodeit/util/FileUtils.java b/src/main/java/com/sprint/mission/discodeit/util/FileUtils.java deleted file mode 100644 index 7247d89cd..000000000 --- a/src/main/java/com/sprint/mission/discodeit/util/FileUtils.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.sprint.mission.discodeit.util; - -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.ObjectInputStream; -import java.io.ObjectOutputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.LinkedHashSet; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -public class FileUtils { - - public static void initDirectory(Path directory) { - if (!Files.exists(directory)) { - try { - Files.createDirectories(directory); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - } - - - public static void save(Path filePath, T data) { - try ( - FileOutputStream fos = new FileOutputStream(filePath.toFile()); - ObjectOutputStream oos = new ObjectOutputStream(fos); - ) { - oos.writeObject(data); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - public static Set load(Path directory) { - Set set = new LinkedHashSet<>(); - if (Files.exists(directory)) { - try (Stream paths = Files.list(directory)){ - set = paths - .map(path -> { - try ( - FileInputStream fis = new FileInputStream(path.toFile()); - ObjectInputStream ois = new ObjectInputStream(fis) - ) { - Object data = ois.readObject(); - return (T) data; - } catch (IOException | ClassNotFoundException e) { - throw new RuntimeException(e); - } - }).collect(Collectors.toCollection(LinkedHashSet::new)); - return set; - } catch (IOException e) { - throw new RuntimeException(e); - } - } else { - return set; - } - } - - public static void remove(Path directory) { - if (Files.exists(directory)) { - try { - Files.delete(directory); - } catch (IOException e) { - e.printStackTrace(); - } - } - - } -} diff --git a/src/main/java/com/sprint/mission/discodeit/util/LogParameterFormatter.java b/src/main/java/com/sprint/mission/discodeit/util/LogParameterFormatter.java new file mode 100644 index 000000000..3e5d769a9 --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/util/LogParameterFormatter.java @@ -0,0 +1,128 @@ +package com.sprint.mission.discodeit.util; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.stream.Collectors; + +public class LogParameterFormatter { + + public static String getFormattedParameters(Object[] datas) { + if (datas == null || datas.length == 0) { + return ""; + } + + StringBuilder sb = new StringBuilder(); + for (Object data : datas) { + if (data == null) { + sb.append("null"); + } else { + sb.append(formatSingleParameterSafely(data)); + } + sb.append(", "); + } + + return sb.length() > 2 ? sb.substring(0, sb.length() - 2) : ""; + } + + private static String formatSingleParameterSafely(Object data) { + try { + Class clazz = data.getClass(); + String name = clazz.getSimpleName(); + + // JDK 내장 클래스들은 toString() 사용 (UUID, LocalDateTime 등) + if (isJdkInternalClass(clazz)) { + return formatField(name, data.toString()); + } + + // 원시타입 래퍼 클래스나 String인 경우 + if (isPrimitiveWrapper(clazz) || data instanceof String) { + return formatField(name, data); + } + + // 사용자 정의 클래스만 리플렉션으로 필드 분석 + return formatUserDefinedObject(name, data); + + } catch (Exception e) { + return data.getClass().getSimpleName() + "="; + } + } + + private static String formatUserDefinedObject(String className, Object data) { + StringBuilder sb = new StringBuilder(); + sb.append(className).append(" = ("); + + Field[] fields = data.getClass().getDeclaredFields(); + String fieldsStr = Arrays.stream(fields) + .map(field -> formatFieldSafely(field, data)) + .filter(result -> result != null) + .collect(Collectors.joining(", ")); + + sb.append(fieldsStr).append(")"); + return sb.toString(); + } + + private static String formatFieldSafely(Field field, Object obj) { + try { + field.setAccessible(true); + + int modifiers = field.getModifiers(); + Object value; + + if (Modifier.isStatic(modifiers)) { + value = field.get(null); + } else { + value = field.get(obj); + } + + if (isSensitiveField(field.getName())) { + return formatFieldWithModifier(field.getName(), "***MASKED***", modifiers); + } + + return formatFieldWithModifier(field.getName(), value, modifiers); + + } catch (Exception e) { + // access_denied 오류를 로그에서 제거하고 필드 자체를 생략 + return null; // null 반환하면 filter에서 제외됨 + } + } + + private static String formatFieldWithModifier(String fieldName, Object value, int modifiers) { + StringBuilder prefix = new StringBuilder(); + + if (Modifier.isStatic(modifiers)) { + prefix.append("static "); + } + + if (Modifier.isFinal(modifiers)) { + prefix.append("final "); + } + + return String.format("%s%s=%s", prefix.toString(), fieldName, value); + } + + private static boolean isJdkInternalClass(Class clazz) { + String packageName = clazz.getPackageName(); + return packageName.startsWith("java.") || + packageName.startsWith("javax.") || + packageName.startsWith("sun.") || + packageName.startsWith("com.sun.") || + packageName.startsWith("jdk."); + } + + private static boolean isPrimitiveWrapper(Class clazz) { + return clazz == Integer.class || clazz == Long.class || + clazz == Double.class || clazz == Float.class || + clazz == Boolean.class || clazz == Character.class || + clazz == Byte.class || clazz == Short.class; + } + + private static boolean isSensitiveField(String fieldName) { + String lowerName = fieldName.toLowerCase(); + return lowerName.contains("password"); + } + + private static String formatField(String name, Object data) { + return String.format("%s=%s", name, data); + } +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml new file mode 100644 index 000000000..235a2dd36 --- /dev/null +++ b/src/main/resources/application-dev.yaml @@ -0,0 +1,46 @@ +spring: + application: + name: discodeit + boot: + admin: + client: + url: http://localhost:9090 + instance: + service-base-url: http://localhost:80 + datasource: + url: jdbc:postgresql://localhost:5432/discodeit + username: discodeit_user + password: discode1234 + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + open-in-view: false + +jwt: + secret: "85u092j4aa023jfk320fawjoi3thta0hataljnvh289ht4thafhkwghkfgg2ita4thjlfhi2u3falbtbgiuy" + access-token-expiration: 3600 # 1시간 + refresh-token-expiration: 172800 # 1일 + max-active-count: 1 + +logging: + level: + com: + sprint: + mission: + discodeit: DEBUG + org: + hibernate: + SQL: DEBUG + orm: + jdbc: + bind: DEBUG + springframework: + security: DEBUG + cache: DEBUG + interceptor: TRACE + + diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml new file mode 100644 index 000000000..a76effd2a --- /dev/null +++ b/src/main/resources/application-prod.yaml @@ -0,0 +1,31 @@ + +spring: + application: + name: discodeit + boot: + admin: + client: + url: ${SPRING_BOOT_ADMIN_CLIENT_URL} + datasource: + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: false + open-in-view: false + +logging: + level: + com: + sprint: + mission: + discodeit: INFO + org: + hibernate: + SQL: INFO + diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 5fb0959f1..a4b6f5b92 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,10 +1,99 @@ +spring: + profiles: + active: dev + info: + app: + name: Discodeit + version: 1.7.0 + java: + version: 17 + boot: + version: 3.5.0 + application: + name: discodeit + config: + datasource: + url: jdbc:postgresql://localhost:5432/discodeit + username: discodeit_user + password: discode1234 + driver-class-name: org.postgresql.Driver + jpa: + ddl-auto: none + multipart: + max-file-size: 50MB + max-request-size: 50MB + cache: + cache-names: + - channels + - notifications + - users + caffeine: + spec: + initialCapacity=100, + maximumSize=1000, + expireAfterAccess=30m, + recordStats + redis: + enable-statistics: true + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + kafka: + bootstrap-servers: localhost:9092 + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer + consumer: + group-id: discodeit-group + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + +logging: + level: + root: INFO + +server: + port: 80 + +management: + observations: + annotations: + enabled: true + endpoints: + web: + exposure: + include: "*" + base-path: /actuator + endpoint: + health: + show-details: never + info: + enabled: true + metrics: + enabled: true + loggers: + enabled: true + caches: + enabled: true + + discodeit: - repository: - #type: jcf # jcf | file - channel : ./data/channel - message : ./data/message - user : ./data/user - readStatus : ./data/readStatus - userStatus : ./data/userStatus - binaryContent : ./data/binaryContent + storage: + type: ${STORAGE_TYPE:local} # 기본 값 local + local: + root-path: ${STORAGE_LOCAL_ROOT_PATH:.discoedit/storage} + s3: + access-key: ${AWS_S3_ACCESS_KEY} + secret-key: ${AWS_S3_SECRET_KEY} + region: ${AWS_S3_REGION} + bucket: ${AWS_S3_BUCKET} + presigned-url-expiration: ${AWS_S3_PRESIGNED_URL_EXPIRATION:600} # 기본값 10분 + path: ./discodeit/local/storage + +admin: + username: "admin" + email: "admin@admin.com" + password: "admin1234" diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..bf8304631 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,40 @@ + + + + + + + + + ${LOG_PATTERN} + + + ${LOG_PATTERN} + ${LOG_PATH}/${LOG_FILE_NAME} + + ${LOG_PATH}/archive/application.%d{yyyy-MM-dd}.log + 30 + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 000000000..f0bddfe9b --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,64 @@ +CREATE TABLE binary_contents ( + id UUID PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at timestamp with time zone, + file_name VARCHAR(255) NOT NULL, + size BIGINT NOT NULL, + content_type VARCHAR(100) NOT NULL, + status varchar(20) NOT NULL, + bytes BYTEA NOT NULL +); + +CREATE TABLE channels ( + id UUID PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + name VARCHAR(100), + description VARCHAR(500), + type VARCHAR(10) NOT NULL CHECK (type IN ('PUBLIC', 'PRIVATE')) +); + +CREATE TABLE users ( + id UUID PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + password VARCHAR(60) NOT NULL, + role VARCHAR(20) NOT NULL, + profile_id UUID REFERENCES binary_contents(id) ON DELETE SET NULL +); + +CREATE TABLE user_statuses ( + id UUID PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE, + user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + last_active_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE TABLE read_statuses ( + id UUID PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + notification_enabled boolean NOT NULL, + last_read_at TIMESTAMP WITH TIME ZONE NOT NULL, + UNIQUE(user_id, channel_id) +); + +CREATE TABLE messages ( + id UUID PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE, + content TEXT, + channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + author_id UUID REFERENCES users(id) ON DELETE SET NULL +); + +CREATE TABLE message_attachments ( + message_id UUID REFERENCES messages(id) ON DELETE CASCADE, + attachment_id UUID REFERENCES binary_contents(id) ON DELETE CASCADE, + PRIMARY KEY (message_id, attachment_id) +); diff --git a/src/main/resources/static/default-profile.png b/src/main/resources/static/default-profile.png deleted file mode 100644 index 2ce105a6b..000000000 Binary files a/src/main/resources/static/default-profile.png and /dev/null differ diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html deleted file mode 100644 index 559a77237..000000000 --- a/src/main/resources/static/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - 사용자 목록 - - - -
-

사용자 목록

-
- -
-
- - - diff --git a/src/main/resources/static/script.js b/src/main/resources/static/script.js deleted file mode 100644 index 2b4d79bf1..000000000 --- a/src/main/resources/static/script.js +++ /dev/null @@ -1,67 +0,0 @@ -// API endpoints -const API_BASE_URL = '/api'; -const ENDPOINTS = { - USERS: `${API_BASE_URL}/users`, - BINARY_CONTENT: `${API_BASE_URL}/binarycontents` -}; - -// Initialize the application -document.addEventListener('DOMContentLoaded', () => { - fetchAndRenderUsers(); -}); - -// Fetch users from the API -async function fetchAndRenderUsers() { - try { - const response = await fetch(ENDPOINTS.USERS); - if (!response.ok) throw new Error('Failed to fetch users'); - const users = await response.json(); - renderUserList(users); - } catch (error) { - console.error('Error fetching users:', error); - } -} - -// Fetch user profile image -async function fetchUserProfile(profileId) { - try { - const response = await fetch(`${ENDPOINTS.BINARY_CONTENT}/${profileId}`); - if (!response.ok) throw new Error('Failed to fetch profile'); - const profile = await response.json(); - - // Convert base64 encoded bytes to data URL - return `data:${profile.contentType};base64,${profile.data}`; - } catch (error) { - console.error('Error fetching profile:', error); - return '/default-profile.png'; // Fallback to default avatar - } -} - -// Render user list -async function renderUserList(users) { - const userListElement = document.getElementById('userList'); - userListElement.innerHTML = ''; // Clear existing content - - for (const user of users) { - const userElement = document.createElement('div'); - userElement.className = 'user-item'; - - // Get profile image URL - const profileUrl = user.profileId ? - await fetchUserProfile(user.profileId) : - '/default-profile.png'; - - userElement.innerHTML = ` - ${user.userName} - -
- ${user.online ? '온라인' : '오프라인'} -
- `; - - userListElement.appendChild(userElement); - } -} diff --git a/src/main/resources/static/styles.css b/src/main/resources/static/styles.css deleted file mode 100644 index b45f4e704..000000000 --- a/src/main/resources/static/styles.css +++ /dev/null @@ -1,80 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: Arial, sans-serif; - background-color: #f5f5f5; -} - -.container { - max-width: 800px; - margin: 0 auto; - padding: 20px; -} - -h1 { - text-align: center; - margin-bottom: 30px; - color: #333; -} - -.user-list { - background-color: white; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.user-item { - display: flex; - align-items: center; - padding: 20px; - border-bottom: 1px solid #eee; -} - -.user-item:last-child { - border-bottom: none; -} - -.user-avatar { - width: 60px; - height: 60px; - border-radius: 50%; - margin-right: 20px; - object-fit: cover; -} - -.user-info { - flex-grow: 1; -} - -.user-name { - font-size: 18px; - font-weight: bold; - color: #333; - margin-bottom: 5px; -} - -.user-email { - font-size: 14px; - color: #666; -} - -.status-badge { - padding: 6px 12px; - border-radius: 20px; - font-size: 14px; - font-weight: bold; -} - -.online { - background-color: #4CAF50; - color: white; -} - -.offline { - background-color: #9e9e9e; - color: white; -} \ No newline at end of file diff --git a/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java b/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java index 3a987a214..16d79d42a 100644 --- a/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java +++ b/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java @@ -1,8 +1,10 @@ package com.sprint.mission.discodeit; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +@Disabled @SpringBootTest class DiscodeitApplicationTests { diff --git a/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java new file mode 100644 index 000000000..d33faad05 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java @@ -0,0 +1,239 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.constant.ChannelErrorCode; +import com.sprint.mission.discodeit.dto.channel.ChannelResponse; +import com.sprint.mission.discodeit.dto.channel.request.ChannelCreateRequest; +import com.sprint.mission.discodeit.dto.channel.request.ChannelCreateServiceRequest; +import com.sprint.mission.discodeit.dto.channel.request.ChannelUpdateRequest; +import com.sprint.mission.discodeit.dto.channel.request.ChannelUpdateServiceRequest; +import com.sprint.mission.discodeit.dto.channel.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.channel.request.PrivateChannelCreateServiceRequest; +import com.sprint.mission.discodeit.dto.user.UserResponse; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.ChannelException; +import com.sprint.mission.discodeit.service.ChannelService; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +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.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ChannelController.class) +public class ChannelControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ChannelService channelService; + + @Test + @DisplayName("공개 채널 생성가 성공한다") + void createPublicChannel_Success() throws Exception { + //given + ChannelCreateRequest request = new ChannelCreateRequest("공개 채널", "공개 채널"); + + UUID channelId = UUID.randomUUID(); + ChannelResponse response = new ChannelResponse(channelId, ChannelType.PUBLIC, "공개 채널", "공개 채널", + new ArrayList<>(), Instant.now()); + + given(channelService.createPublicChannel(any(ChannelCreateServiceRequest.class))).willReturn(response); + + //when & then + mockMvc.perform(post("/api/channels/public") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(channelId.toString())) + .andExpect(jsonPath("$.name").value("공개 채널")) + .andExpect(jsonPath("$.description").value("공개 채널")); + + } + + @Test + @DisplayName("입력값 제약조건 미준수 시 공개 채널 생성이 실패한다") + void createPublicChannel_Fail() throws Exception { + //given + ChannelCreateRequest request = new ChannelCreateRequest( + "", // 1자 ~ 100자 이어야 함 + "A".repeat(501) // 1자 ~ 500자 이어야함 + ); + + //when & then + mockMvc.perform(post("/api/channels/public") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + + } + + @Test + @DisplayName("비공개 채널 생성을 성공한다") + void createPrivateChannel_Success() throws Exception { + //given + UUID firstId = UUID.randomUUID(); + UUID secondId = UUID.randomUUID(); + UUID thirdId = UUID.randomUUID(); + + List participantIds = List.of(firstId, secondId, thirdId); + PrivateChannelCreateRequest request = new PrivateChannelCreateRequest(participantIds); + + List users = new ArrayList<>(); + for (UUID participantId : participantIds) { + User user = new User("user", "user@user.com", "user", null); + ReflectionTestUtils.setField(user, "id", participantId); + users.add(new UserResponse(user)); + } + + ChannelResponse response = new ChannelResponse(UUID.randomUUID(), ChannelType.PRIVATE, "비공개 채널", "비공개 채널", + users, Instant.now()); + + given(channelService.createPrivateChannel(any(PrivateChannelCreateServiceRequest.class))).willReturn(response); + + //when & then + mockMvc.perform(post("/api/channels/private") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(response.getId().toString())) + .andExpect(jsonPath("$.name").value("비공개 채널")) + .andExpect(jsonPath("$.description").value("비공개 채널")); + } + + @Test + @DisplayName("공개 채널 업데이트를 성공한다") + void updatePublicChannel_Success() throws Exception { + //given + UUID id = UUID.randomUUID(); + ChannelUpdateRequest request = new ChannelUpdateRequest("공개 채널 수정", "공개 채널 수정"); + + ChannelResponse response = new ChannelResponse(id, ChannelType.PUBLIC, "공개 채널 수정", "공개 채널 수정", null, + Instant.now()); + + given(channelService.updateChannel(any(ChannelUpdateServiceRequest.class))).willReturn(response); + + // when & then + mockMvc.perform(patch("/api/channels/{channelId}", id) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ).andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(id.toString())) + .andExpect(jsonPath("$.name").value("공개 채널 수정")) + .andExpect(jsonPath("$.description").value("공개 채널 수정")); + } + + @Test + @DisplayName("비공개 채널 업데이트를 실패한다") + void updatePrivateChannel_Fail() throws Exception { + //given + UUID id = UUID.randomUUID(); + ChannelUpdateRequest request = new ChannelUpdateRequest("비공개 채널 수정", "비공개 채널 수정"); + + given(channelService.updateChannel(any(ChannelUpdateServiceRequest.class))).willThrow(new ChannelException( + ChannelErrorCode.PRIVATE_CHANNEL_NOT_EDITABLE)); + + // when & then + mockMvc.perform(patch("/api/channels/{channelId}", id) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ).andExpect(status().isForbidden()); + } + + @Test + @DisplayName("없는 채널 업데이트 시도 시 실패한다") + void updateNotExistChannel_Fail() throws Exception { + //given + UUID id = UUID.randomUUID(); + ChannelUpdateRequest request = new ChannelUpdateRequest("공개 채널 수정", "공개 채널 수정"); + + given(channelService.updateChannel(any(ChannelUpdateServiceRequest.class))).willThrow(new ChannelException( + ChannelErrorCode.CHANNEL_NOT_FOUND)); + + // when & then + mockMvc.perform(patch("/api/channels/{channelId}", id) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ).andExpect(status().isNotFound()); + } + + @Test + @DisplayName("채널 삭제를 성공한다") + void deleteChannel_Success() throws Exception { + //given + UUID channelId = UUID.randomUUID(); + willDoNothing().given(channelService).deleteChannel(any(UUID.class)); + + //when & then + mockMvc.perform(delete("/api/channels/{channelId}", channelId)) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("없는 채널 삭제 시도 시 실패한다") + void deleteChannelFailIfChannelNotExist() throws Exception { + //given + UUID channelId = UUID.randomUUID(); + willThrow(new ChannelException(ChannelErrorCode.CHANNEL_NOT_FOUND)).given(channelService) + .deleteChannel(any(UUID.class)); + + //when & then + mockMvc.perform(delete("/api/channels/{channelId}", channelId)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("사용자는 자신이 참여한 채널만 조회 가능하다") + void findAllByUserId_Success() throws Exception { + //given + UUID firstUserId = UUID.randomUUID(); + UUID secondUserId = UUID.randomUUID(); + + User firstUser = new User("test", "test@test.com", "test", null); + ReflectionTestUtils.setField(firstUser, "id", firstUserId); + + User secondUser = new User("test", "test@test.com", "test", null); + ReflectionTestUtils.setField(secondUser, "id", secondUserId); + List users = List.of(new UserResponse(firstUser), new UserResponse(secondUser)); + + List channels = new ArrayList<>(); + channels.add( + new ChannelResponse(UUID.randomUUID(), ChannelType.PUBLIC, "공개 채널", "공개 채널", List.of(), Instant.now())); + channels.add( + new ChannelResponse(UUID.randomUUID(), ChannelType.PRIVATE, "비공개 채널", "비공개 채널", users, Instant.now())); + + given(channelService.findAllChannelsByUserId(any(UUID.class))).willReturn(channels); + + //when & then + mockMvc.perform(get("/api/channels?userId={userId}", firstUserId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(channels.get(0).getId().toString())) + .andExpect(jsonPath("$[0].name").value(channels.get(0).getName())) + .andExpect(jsonPath("$[0].description").value(channels.get(0).getDescription())) + .andExpect(jsonPath("$[1].id").value(channels.get(1).getId().toString())) + .andExpect(jsonPath("$[1].name").value(channels.get(1).getName())) + .andExpect(jsonPath("$[1].description").value(channels.get(1).getDescription())); + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java new file mode 100644 index 000000000..b8252f00f --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java @@ -0,0 +1,280 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.constant.MessageErrorCode; +import com.sprint.mission.discodeit.dto.PageResponse; +import com.sprint.mission.discodeit.dto.message.MessageResponse; +import com.sprint.mission.discodeit.dto.message.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.message.request.MessageCreateServiceRequest; +import com.sprint.mission.discodeit.dto.message.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.message.request.MessageUpdateServiceRequest; +import com.sprint.mission.discodeit.dto.user.UserResponse; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.MessageException; +import com.sprint.mission.discodeit.service.MessageService; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +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.web.servlet.WebMvcTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(MessageController.class) +public class MessageControllerTest { + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private MessageService messageService; + + @Test + @DisplayName("텍스트만 포함한 메시지 생성에 성공한다") + void createTextMessage_Success() throws Exception { + //given + UUID channelId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + UUID messageId = UUID.randomUUID(); + + MessageCreateRequest messageCreateRequest = new MessageCreateRequest("test", channelId, authorId); + + User user = new User("testUser", "testUser", "testUser", null); + + ReflectionTestUtils.setField(user, "id", authorId); + + Channel channel = Channel.builder() + .name("testChannel") + .description("description") + .type(ChannelType.PUBLIC) + .build(); + + Message message = new Message("test", channel, user); + + ReflectionTestUtils.setField(message, "id", messageId); + + UserResponse userResponse = new UserResponse(user); + + MessageResponse messageResponse = new MessageResponse(messageId, Instant.now(),Instant.now(),"test", channelId, userResponse, new ArrayList<>()); + + given(messageService.createMessage(any(MessageCreateServiceRequest.class))).willReturn(messageResponse); + + //when & then + mockMvc.perform(multipart("/api/messages") + .file(new MockMultipartFile("messageCreateRequest", "", MediaType.APPLICATION_JSON_VALUE, objectMapper.writeValueAsString(messageCreateRequest).getBytes())) + .contentType(MediaType.MULTIPART_FORM_DATA) + ) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(messageId.toString())) + .andExpect(jsonPath("$.content").value("test")) + .andExpect(jsonPath("$.author.id").value(authorId.toString())) + .andExpect(jsonPath("$.author.username").value("testUser")) + .andExpect(jsonPath("$.channelId").value(channelId.toString())); + + } + + @Test + @DisplayName("이미지를 여러개 포함한 메시지 생성에 성공한다") + void createImageMessage_Success() throws Exception { + //given + UUID channelId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + UUID messageId = UUID.randomUUID(); + + MessageCreateRequest messageCreateRequest = new MessageCreateRequest("content", channelId, authorId); + + User user = new User("testUser", "testUser", "testUser", null); + + ReflectionTestUtils.setField(user, "id", authorId); + + Channel channel = Channel.builder() + .name("testChannel") + .description("description") + .type(ChannelType.PUBLIC) + .build(); + + Message message = new Message("test", channel, user); + + ReflectionTestUtils.setField(message, "id", messageId); + + UserResponse userResponse = new UserResponse(user); + + MessageResponse messageResponse = new MessageResponse(messageId, Instant.now(),Instant.now(),"test", channelId, userResponse, new ArrayList<>()); + + MockMultipartFile file =new MockMultipartFile("attachments", "test.jpg", "image/jpeg", "test".getBytes()); + MockMultipartFile file2 =new MockMultipartFile("attachments", "test2.jpg", "image/jpeg", "test2".getBytes()); + + + given(messageService.createMessage(any(MessageCreateServiceRequest.class))).willReturn(messageResponse); + + //when & then + mockMvc.perform(multipart("/api/messages") + .file(new MockMultipartFile("messageCreateRequest", "", MediaType.APPLICATION_JSON_VALUE, objectMapper.writeValueAsString(messageCreateRequest).getBytes())) + .file(file) + .file(file2) + .contentType(MediaType.MULTIPART_FORM_DATA) + ) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(messageId.toString())) + .andExpect(jsonPath("$.content").value("test")) + .andExpect(jsonPath("$.author.id").value(authorId.toString())) + .andExpect(jsonPath("$.author.username").value("testUser")) + .andExpect(jsonPath("$.channelId").value(channelId.toString())); + } + + @Test + @DisplayName("메시지 수정에 성공한다") + void updateMessage_Success() throws Exception { + //given + UUID messageId = UUID.randomUUID(); + UUID channelId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + + MessageUpdateRequest messageUpdateRequest = new MessageUpdateRequest("update", channelId, authorId); + + User user = new User("testUser", "testUser", "testUser", null); + + ReflectionTestUtils.setField(user, "id", authorId); + + Channel channel = Channel.builder() + .name("testChannel") + .description("description") + .type(ChannelType.PUBLIC) + .build(); + + Message message = new Message("test", channel, user); + + ReflectionTestUtils.setField(message, "id", messageId); + + UserResponse userResponse = new UserResponse(user); + + MessageResponse messageResponse = new MessageResponse(messageId, Instant.now(),Instant.now(),"update", channelId, userResponse, new ArrayList<>()); + + given(messageService.updateContent(any(MessageUpdateServiceRequest.class))).willReturn(messageResponse); + + //when & then + mockMvc.perform(patch("/api/messages/{messageId}", messageId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(messageUpdateRequest)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(messageId.toString())) + .andExpect(jsonPath("$.content").value("update")) + .andExpect(jsonPath("$.author.id").value(authorId.toString())) + .andExpect(jsonPath("$.author.username").value("testUser")) + .andExpect(jsonPath("$.channelId").value(channelId.toString())); + } + + @Test + @DisplayName("메시지 삭제에 성공한다") + void deleteMessage_Success() throws Exception { + //given + UUID messageId = UUID.randomUUID(); + + willDoNothing().given(messageService).deleteMessage(any(UUID.class)); + //when & then + mockMvc.perform(delete("/api/messages/{messageId}", messageId)) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("없는 메시지 삭제에 실패한다") + void deleteMessage_Fail_WhenMessageNotExist() throws Exception { + //given + UUID messageId = UUID.randomUUID(); + + willThrow(new MessageException(MessageErrorCode.MESSAGE_NOT_FOUND)).given(messageService).deleteMessage(any(UUID.class)); + //when & then + mockMvc.perform(delete("/api/messages/{messageId}", messageId)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("채널 메시지 페이징 조회를 성공한다") + void findAllByChannelId_Success() throws Exception { + //given + UUID channelId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + UUID firstMessageId = UUID.randomUUID(); + UUID secondMessageId = UUID.randomUUID(); + + Instant cursor = Instant.now(); + Pageable pageable = PageRequest.of(0, 10, Sort.Direction.DESC, "createdAt"); + + User user = new User("testUser", "testUser", "testUser", null); + + ReflectionTestUtils.setField(user, "id", authorId); + + Channel channel = Channel.builder() + .name("testChannel") + .description("description") + .type(ChannelType.PUBLIC) + .build(); + + Message firstMessage = new Message("test", channel, user); + ReflectionTestUtils.setField(firstMessage, "id", firstMessageId); + + Message secondMessage = new Message("test2", channel, user); + ReflectionTestUtils.setField(secondMessage, "id", secondMessageId); + + List messageResponses = new ArrayList<>(); + messageResponses.add(new MessageResponse(firstMessage.getId(), Instant.now(), Instant.now(), "test", channelId, new UserResponse(user), new ArrayList<>())); + messageResponses.add(new MessageResponse(secondMessage.getId(), Instant.now(), Instant.now(), "test2", channelId, new UserResponse(user), new ArrayList<>())); + + PageResponse pageResponse = new PageResponse<>( + pageable.getPageSize(), + true, + messageResponses, + cursor.minusSeconds(10), + (long) messageResponses.size() + ); + + given(messageService.findMessagesByChannelId(any(UUID.class), any(Instant.class), any(Pageable.class))).willReturn(pageResponse); + + //when & then + mockMvc.perform(get("/api/messages") + .param("channelId", channelId.toString()) + .param("cursor", cursor.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value(messageResponses.get(0).getId().toString())) + .andExpect(jsonPath("$.content[0].content").value(messageResponses.get(0).getContent())) + .andExpect(jsonPath("$.content[0].author.id").value(authorId.toString())) + .andExpect(jsonPath("$.content[0].author.username").value("testUser")) + .andExpect(jsonPath("$.content[0].channelId").value(channelId.toString())) + .andExpect(jsonPath("$.content[1].id").value(messageResponses.get(1).getId().toString())) + .andExpect(jsonPath("$.content[1].content").value(messageResponses.get(1).getContent())) + .andExpect(jsonPath("$.content[1].author.id").value(authorId.toString())) + .andExpect(jsonPath("$.content[1].author.username").value("testUser")) + .andExpect(jsonPath("$.content[1].channelId").value(channelId.toString())) + .andExpect(jsonPath("$.size").value(10)) + .andExpect(jsonPath("$.hasNext").value(true)) + .andExpect(jsonPath("$.totalElements").value(2)); + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java new file mode 100644 index 000000000..60eaf8e64 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java @@ -0,0 +1,273 @@ +package com.sprint.mission.discodeit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.constant.UserErrorCode; +import com.sprint.mission.discodeit.dto.user.UserResponse; +import com.sprint.mission.discodeit.dto.user.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.user.request.UserCreateServiceRequest; +import com.sprint.mission.discodeit.dto.user.request.UserUpdateRequest; +import com.sprint.mission.discodeit.dto.user.request.UserUpdateServiceRequest; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.UserException; +import com.sprint.mission.discodeit.service.UserService; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +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.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = UserController.class) +public class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private UserService userService; + + @Test + @DisplayName("기본 프로필을 가진 유저를 생성한다") + void createUserWithoutProfile() throws Exception { + //given + UserCreateRequest userCreateRequest = new UserCreateRequest("test","test@test.com","test",null); + User user = new User("test","test@test.com","test",null); + UUID uuid = UUID.randomUUID(); + ReflectionTestUtils.setField(user, "id", uuid); + given(userService.createUser(any(UserCreateServiceRequest.class))).willReturn(new UserResponse(user)); + + //when & then + mockMvc.perform(multipart("/api/users") + .file(new MockMultipartFile("userCreateRequest", "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsString(userCreateRequest).getBytes()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(uuid.toString())) + .andExpect(jsonPath("$.username").value("test")) + .andExpect(jsonPath("$.email").value("test@test.com")) + .andExpect(jsonPath("$.profile").doesNotExist()); + } + + @Test + @DisplayName("프로필과 함께 유저를 생성한다") + void createUserWithProfile() throws Exception { + //given + MockMultipartFile profile = new MockMultipartFile("file", "test-image.jpg", "image/jpeg", "content".getBytes()); + UserCreateRequest userCreateRequest = new UserCreateRequest("test","test@test.com","test",null ); + User user = new User("test","test@test.com","test",null); + UUID uuid = UUID.randomUUID(); + + user.updateProfile(new BinaryContent("file", "image/jpeg", profile.getSize(), "content".getBytes())); + ReflectionTestUtils.setField(user, "id", uuid); + given(userService.createUser(any(UserCreateServiceRequest.class))).willReturn(new UserResponse(user)); + + //when & then + mockMvc.perform(multipart("/api/users") + .file(new MockMultipartFile("userCreateRequest", "", + "application/json", + objectMapper.writeValueAsString(userCreateRequest).getBytes()) + ) + .file(profile) + .contentType(MediaType.MULTIPART_FORM_DATA) + ) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(uuid.toString())) + .andExpect(jsonPath("$.username").value("test")) + .andExpect(jsonPath("$.email").value("test@test.com")) + .andExpect(jsonPath("$.profile.fileName").value("file")); + + } + + @Test + @DisplayName("계정 생성 정책을 준수하지 않으면 사용자 생성에 실패한다") + void createUserFailWhenUserPolicyDoesNotSatisfy() throws Exception{ + //given + UserCreateRequest invalidRequest = new UserCreateRequest( + "test", //null, 빈문자열 미허용, 1자 이상 50자 이하 + "test", // 이메일 형식 준수, 1자 이상 100자 이하 + "test", //null, 빈문자열 미허용, 1자 이상 60자 이하 + null); + + MockMultipartFile createRequest = new MockMultipartFile("userCreateRequest", "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsString(invalidRequest).getBytes()); + + //when & then + mockMvc.perform(multipart("/api/users") + .file(createRequest) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isBadRequest()); + + } + + @Test + @DisplayName("ID로 유저 조회 성공") + void findByIdSuccess() throws Exception{ + //given + UUID uuid = UUID.randomUUID(); + User user = new User("test","test@test.com","test",null); + ReflectionTestUtils.setField(user, "id", uuid); + given(userService.findUserById(any(UUID.class))).willReturn(new UserResponse(user)); + + //when & then + mockMvc.perform(get("/api/users/{id}",uuid)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(uuid.toString())) + .andExpect(jsonPath("$.username").value("test")) + .andExpect(jsonPath("$.email").value("test@test.com")); + + } + + @Test + @DisplayName("없는 사용자 조회 시 실패") + void findByIdFailWhenUserNotExist() throws Exception { + //given + UUID uuid = UUID.randomUUID(); + User user = new User("test","test@test.com","test",null); + given(userService.findUserById(any(UUID.class))).willThrow(new UserException(UserErrorCode.USER_NOT_FOUND)); + + //when & then + mockMvc.perform(get("/api/users/{id}",uuid)) + .andExpect(status().isNotFound()); + + } + + @Test + @DisplayName("사용자 전체 조회") + void findAllUsers() throws Exception { + //given + User firstUser = new User("test","test@test.com","test",null); + UUID firstUserId = UUID.randomUUID(); + + User secondUser = new User("test2","test2@test.com","test2",null); + UUID secondUserId = UUID.randomUUID(); + + ReflectionTestUtils.setField(firstUser, "id", firstUserId); + ReflectionTestUtils.setField(secondUser, "id", secondUserId); + + List userResponses = Arrays.asList(new UserResponse(firstUser), new UserResponse(secondUser)); + given(userService.findUsers()).willReturn(userResponses); + + //when & then + mockMvc.perform(get("/api/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(firstUserId.toString())) + .andExpect(jsonPath("$[0].username").value("test")) + .andExpect(jsonPath("$[0].email").value("test@test.com")) + .andExpect(jsonPath("$[0].online").value(true)) + .andExpect(jsonPath("$[1].id").value(secondUserId.toString())) + .andExpect(jsonPath("$[1].username").value("test2")) + .andExpect(jsonPath("$[1].email").value("test2@test.com")) + .andExpect(jsonPath("$[1].online").value(true)); + + } + + @Test + @DisplayName("사용자 업데이트를 성공한다") + void updateUser() throws Exception { + //given + UUID uuid = UUID.randomUUID(); + User user = new User("test","test@test.com","test",null); + ReflectionTestUtils.setField(user, "id", uuid); + UserUpdateRequest updateRequest = new UserUpdateRequest("update","update@update.com","update"); + + User updatedUser = new User("update","update@update.com","update",null); + ReflectionTestUtils.setField(updatedUser, "id", uuid); + + + BinaryContent binaryContent = new BinaryContent("updatedProfile.jpg", "image/jpeg", 0L, "".getBytes()); + updatedUser.updateProfile(binaryContent); + + + given(userService.updateUser(any(UserUpdateServiceRequest.class))).willReturn(new UserResponse(updatedUser)); + + MockMultipartFile userUpdateRequestPart = new MockMultipartFile("userUpdateRequest", "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsString(updateRequest).getBytes()); + + MockMultipartFile profilePart = new MockMultipartFile("profile", "updatedProfile.jpg", + MediaType.IMAGE_JPEG_VALUE, + "".getBytes()); + + //when & then + mockMvc.perform(multipart("/api/users/{id}",uuid) + .file(profilePart) + .file(userUpdateRequestPart) + .contentType(MediaType.MULTIPART_FORM_DATA) + .with(request -> { + request.setMethod("PATCH"); + return request; + })) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(uuid.toString())) + .andExpect(jsonPath("$.username").value("update")) + .andExpect(jsonPath("$.email").value("update@update.com")) + .andExpect(jsonPath("$.profile.fileName").value("updatedProfile.jpg")); + } + + @Test + @DisplayName("없는 사용자 업데이트 시도 시 실패") + void updateUserFailWhenUserNotExist() throws Exception { + //given + UUID uuid = UUID.randomUUID(); + UserUpdateRequest updateRequest = new UserUpdateRequest("update","update@update.com","update"); + MockMultipartFile userUpdateRequestPart = new MockMultipartFile("userUpdateRequest", "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsString(updateRequest).getBytes()); + willThrow(new UserException(UserErrorCode.USER_NOT_FOUND)).given(userService).updateUser(any(UserUpdateServiceRequest.class)); + + //when & then + mockMvc.perform(multipart("/api/users/{id}",uuid) + .file(userUpdateRequestPart) + .contentType(MediaType.MULTIPART_FORM_DATA) + .with(request -> { + request.setMethod("PATCH"); + return request; + })) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("사용자 삭제에 성공한다") + void deleteUser() throws Exception { + //given + UUID uuid = UUID.randomUUID(); + willDoNothing().given(userService).deleteUser(any(UUID.class)); + + //when & then + mockMvc.perform(delete("/api/users/{id}",uuid)) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("존재하지 않는 사용자 삭제 시도 시 실패한다") + void deleteUserFailWhenUserNotExist() throws Exception { + //given + UUID uuid = UUID.randomUUID(); + willThrow(new UserException(UserErrorCode.USER_NOT_FOUND)).given(userService).deleteUser(any(UUID.class)); + + //when & then + mockMvc.perform(delete("/api/users/{id}",uuid)) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/entity/ChannelTest.java b/src/test/java/com/sprint/mission/discodeit/entity/ChannelTest.java new file mode 100644 index 000000000..ca151fae1 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/entity/ChannelTest.java @@ -0,0 +1,43 @@ +package com.sprint.mission.discodeit.entity; + +import com.sprint.mission.discodeit.constant.ChannelErrorCode; +import com.sprint.mission.discodeit.exception.ChannelException; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class ChannelTest { + @Test + @DisplayName("private 채널을 생성할 때 name이 있으면 예외가 발생한다") + void createPrivateChannelFailWhenNameIsNotNull() { + //when & then + Assertions.assertThatThrownBy(() -> {Channel channel = Channel.builder() + .name("test") + .type(ChannelType.PRIVATE) + .build(); }).isInstanceOf(ChannelException.class) + .hasMessage(ChannelErrorCode.PRIVATE_CHANNEL_DOES_NOT_HAVE_NAME.getMessage()); + } + + @Test + @DisplayName("private 채널을 생성할 때 description이 있으면 예외가 발생한다") + void createPrivateChannelFailWhenDescriptionIsNotNull() { + //when & then + Assertions.assertThatThrownBy(() -> {Channel channel = Channel.builder() + .description("test") + .type(ChannelType.PRIVATE) + .build(); }).isInstanceOf(ChannelException.class) + .hasMessage(ChannelErrorCode.PRIVATE_CHANNEL_DOES_NOT_HAVE_DESCRIPTION.getMessage()); + } + + @Test + @DisplayName("private 채널을 생성할 때 name과 description이 있으면 예외가 발생한다") + void createPrivateChannelFailWhenNameAndDescriptionIsNotNull() { + //when & then + Assertions.assertThatThrownBy(() -> {Channel channel = Channel.builder() + .name("test") + .description("description") + .type(ChannelType.PRIVATE) + .build(); }).isInstanceOf(ChannelException.class) + .hasMessage(ChannelErrorCode.PRIVATE_CHANNEL_DOES_NOT_HAVE_NAME_AND_DESCRIPTION.getMessage()); + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/integration/ChannelIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/ChannelIntegrationTest.java new file mode 100644 index 000000000..ff64d4d15 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/ChannelIntegrationTest.java @@ -0,0 +1,157 @@ +package com.sprint.mission.discodeit.integration; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.channel.ChannelResponse; +import com.sprint.mission.discodeit.dto.channel.request.ChannelCreateRequest; +import com.sprint.mission.discodeit.dto.channel.request.ChannelUpdateRequest; +import com.sprint.mission.discodeit.dto.channel.request.PrivateChannelCreateRequest; +import com.sprint.mission.discodeit.dto.user.UserResponse; +import com.sprint.mission.discodeit.dto.user.request.UserCreateRequest; +import com.sprint.mission.discodeit.entity.ChannelType; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +public class ChannelIntegrationTest extends IntegrationTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("공개 채널 생성 통합 테스트") + void createPublicChannel() throws Exception { + //given + ChannelCreateRequest channelCreateRequest = new ChannelCreateRequest("공개 채널", "공개 채널"); + + //when + ResultActions resultActions = createPublicChannel(channelCreateRequest); + + //then + resultActions + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.name").value("공개 채널")) + .andExpect(jsonPath("$.description").value("공개 채널")) + .andExpect(jsonPath("$.type").value(ChannelType.PUBLIC.name())) + .andExpect(jsonPath("$.participants").isEmpty()) + .andExpect(jsonPath("$.lastMessageAt").doesNotExist()); + + } + + @Test + @DisplayName("사설 채널 통합 테스트") + void createPrivateChannel() throws Exception { + //given + + UserCreateRequest userCreateRequest = new UserCreateRequest("test","test@test.com","test",null); + MockMultipartFile profilePart = new MockMultipartFile("profile", "test-profile.jpg", "image/jpeg", "".getBytes()); + MockMultipartFile userPart = new MockMultipartFile("userCreateRequest", "","application/json", objectMapper.writeValueAsString(userCreateRequest).getBytes()); + ResultActions resultActions = createUser(userPart, profilePart); + + String firstUserJsonResponse = resultActions.andReturn().getResponse().getContentAsString(); + UserResponse userResponse = objectMapper.readValue(firstUserJsonResponse, UserResponse.class); + UUID firstParticipantId = userResponse.getId(); + + UserCreateRequest secondUserCreateRequest = new UserCreateRequest("test2","test2@test.com","test2",null); + MockMultipartFile secondProfilePart = new MockMultipartFile("profile", "test2-profile.jpg", "image/jpeg", "".getBytes()); + MockMultipartFile secondUserPart = new MockMultipartFile("userCreateRequest", "","application/json", objectMapper.writeValueAsString(secondUserCreateRequest).getBytes()); + resultActions = createUser(secondUserPart, secondProfilePart); + + String secondUserJsonResponse = resultActions.andReturn().getResponse().getContentAsString(); + UserResponse secondUserResponse = objectMapper.readValue(secondUserJsonResponse, UserResponse.class); + UUID secondParticipantId = secondUserResponse.getId(); + + PrivateChannelCreateRequest channelCreateRequest = new PrivateChannelCreateRequest(List.of(firstParticipantId, secondParticipantId)); + + //when & then + mockMvc.perform( + post("/api/channels/private") + .content(objectMapper.writeValueAsString(channelCreateRequest)) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.type").value(ChannelType.PRIVATE.name())) + .andExpect(jsonPath("$.participants").isArray()) + .andExpect(jsonPath("$.participants[0].id").value(firstParticipantId.toString())) + .andExpect(jsonPath("$.participants[1].id").value(secondParticipantId.toString())) + .andExpect(jsonPath("$.lastMessageAt").doesNotExist()); + } + + @Test + @DisplayName("채널 업데이트 테스트") + void updateChannel() throws Exception { + //given + ChannelCreateRequest channelCreateRequest = new ChannelCreateRequest("공개 채널", "공개 채널"); + ResultActions resultActions = createPublicChannel(channelCreateRequest); + + String jsonResponse = resultActions.andReturn().getResponse().getContentAsString(); + ChannelResponse channelResponse = objectMapper.readValue(jsonResponse, ChannelResponse.class); + UUID channelId = channelResponse.getId(); + + ChannelUpdateRequest channelUpdateRequest = new ChannelUpdateRequest("수정 채널", "수정 채널"); + + //when & then + mockMvc.perform( + patch("/api/channels/{channelId}", channelId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(channelUpdateRequest)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(channelId.toString())) + .andExpect(jsonPath("$.type").value(ChannelType.PUBLIC.name())) + .andExpect(jsonPath("$.name").value("수정 채널")) + .andExpect(jsonPath("$.description").value("수정 채널")) + .andExpect(jsonPath("$.participants").isArray()) + .andExpect(jsonPath("$.participants").isEmpty()) //참여자가 없으면 mapper 에서 빈 리스트 할당 + .andExpect(jsonPath("$.lastMessageAt").doesNotExist()); + + } + + @Test + @DisplayName("채널 삭제 통합 테스트") + void deleteChannel() throws Exception { + //given + ChannelCreateRequest channelCreateRequest = new ChannelCreateRequest("공개 채널", "공개 채널"); + ResultActions resultActions = createPublicChannel(channelCreateRequest); + + String jsonResponse = resultActions.andReturn().getResponse().getContentAsString(); + ChannelResponse channelResponse = objectMapper.readValue(jsonResponse, ChannelResponse.class); + UUID channelId = channelResponse.getId(); + + //when & then + mockMvc.perform(delete("/api/channels/{channelId}", channelId)) + .andExpect(status().isNoContent()); + } + + + ResultActions createUser(MockMultipartFile userPart, MockMultipartFile profilePart) throws Exception { + return mockMvc.perform(multipart("/api/users") + .file(userPart) + .file(profilePart) + .contentType(MediaType.MULTIPART_FORM_DATA)); + } + + private ResultActions createPublicChannel(ChannelCreateRequest request) throws Exception { + return mockMvc.perform( + post("/api/channels/public") + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)); + } + +} diff --git a/src/test/java/com/sprint/mission/discodeit/integration/IntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/IntegrationTest.java new file mode 100644 index 000000000..d0ee6a267 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/IntegrationTest.java @@ -0,0 +1,13 @@ +package com.sprint.mission.discodeit.integration; + +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +public class IntegrationTest { +} diff --git a/src/test/java/com/sprint/mission/discodeit/integration/MessageIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/MessageIntegrationTest.java new file mode 100644 index 000000000..e1fcb82da --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/MessageIntegrationTest.java @@ -0,0 +1,170 @@ +package com.sprint.mission.discodeit.integration; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.channel.ChannelResponse; +import com.sprint.mission.discodeit.dto.channel.request.ChannelCreateRequest; +import com.sprint.mission.discodeit.dto.message.MessageResponse; +import com.sprint.mission.discodeit.dto.message.request.MessageCreateRequest; +import com.sprint.mission.discodeit.dto.message.request.MessageUpdateRequest; +import com.sprint.mission.discodeit.dto.user.UserResponse; +import com.sprint.mission.discodeit.dto.user.request.UserCreateRequest; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +public class MessageIntegrationTest extends IntegrationTest{ + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("메시지 생성 통합 테스트") + void createMessage() throws Exception { + //given + UserCreateRequest userCreateRequest = new UserCreateRequest("test","test@test.com","test",null); + MockMultipartFile profilePart = new MockMultipartFile("profile", "test-profile.jpg", "image/jpeg", "".getBytes()); + MockMultipartFile userPart = new MockMultipartFile("userCreateRequest", "","application/json", objectMapper.writeValueAsString(userCreateRequest).getBytes()); + + ResultActions resultUserActions = createUser(userPart, profilePart); + String firstUserJsonResponse = resultUserActions.andReturn().getResponse().getContentAsString(); + UserResponse userResponse = objectMapper.readValue(firstUserJsonResponse, UserResponse.class); + UUID authorId = userResponse.getId(); + + ChannelCreateRequest channelCreateRequest = new ChannelCreateRequest("공개 채널", "공개 채널"); + + + ResultActions resultChannelActions = createPublicChannel(channelCreateRequest); + String jsonResponse = resultChannelActions.andReturn().getResponse().getContentAsString(); + ChannelResponse channelResponse = objectMapper.readValue(jsonResponse, ChannelResponse.class); + UUID channelId = channelResponse.getId(); + + MessageCreateRequest messageCreateRequest = new MessageCreateRequest("content",channelId, authorId); + MockMultipartFile messagePart = new MockMultipartFile("messageCreateRequest", "","application/json", objectMapper.writeValueAsString(messageCreateRequest).getBytes()); + MockMultipartFile messageImagePart = new MockMultipartFile("image", "test-image.jpg", "image/jpeg", "".getBytes()); + + //when + ResultActions resultActions = createMessage(messagePart, messageImagePart); + + //then + resultActions + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.author.id").value(authorId.toString())) + .andExpect(jsonPath("$.channelId").value(channelId.toString())) + .andExpect(jsonPath("$.content").value("content")); + + } + + @Test + @DisplayName("메시지 수정 통합 테스트") + void updateMessage() throws Exception { + //given + UserCreateRequest userCreateRequest = new UserCreateRequest("test","test@test.com","test",null); + MockMultipartFile profilePart = new MockMultipartFile("profile", "test-profile.jpg", "image/jpeg", "".getBytes()); + MockMultipartFile userPart = new MockMultipartFile("userCreateRequest", "","application/json", objectMapper.writeValueAsString(userCreateRequest).getBytes()); + + ResultActions resultUserActions = createUser(userPart, profilePart); + String firstUserJsonResponse = resultUserActions.andReturn().getResponse().getContentAsString(); + UserResponse userResponse = objectMapper.readValue(firstUserJsonResponse, UserResponse.class); + UUID authorId = userResponse.getId(); + + ChannelCreateRequest channelCreateRequest = new ChannelCreateRequest("공개 채널", "공개 채널"); + + ResultActions resultChannelActions = createPublicChannel(channelCreateRequest); + String jsonResponse = resultChannelActions.andReturn().getResponse().getContentAsString(); + ChannelResponse channelResponse = objectMapper.readValue(jsonResponse, ChannelResponse.class); + UUID channelId = channelResponse.getId(); + + MessageCreateRequest messageCreateRequest = new MessageCreateRequest("content",channelId, authorId); + MockMultipartFile messagePart = new MockMultipartFile("messageCreateRequest", "","application/json", objectMapper.writeValueAsString(messageCreateRequest).getBytes()); + MockMultipartFile messageImagePart = new MockMultipartFile("image", "test-image.jpg", "image/jpeg", "".getBytes()); + ResultActions resultActions = createMessage(messagePart, messageImagePart); + + String messageJsonResponse = resultActions.andReturn().getResponse().getContentAsString(); + MessageResponse messageResponse = objectMapper.readValue(messageJsonResponse, MessageResponse.class); + UUID messageId = messageResponse.getId(); + + MessageUpdateRequest request = new MessageUpdateRequest("updatedContent",channelId, authorId); + + //when & then + mockMvc.perform(patch("/api/messages/{messageId}",messageId) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.author.id").value(authorId.toString())) + .andExpect(jsonPath("$.channelId").value(channelId.toString())) + .andExpect(jsonPath("$.content").value("updatedContent")); + + } + + @Test + @DisplayName("메시지 삭제 통합 테스트") + void deleteMessage() throws Exception { + //given + UserCreateRequest userCreateRequest = new UserCreateRequest("test","test@test.com","test",null); + MockMultipartFile profilePart = new MockMultipartFile("profile", "test-profile.jpg", "image/jpeg", "".getBytes()); + MockMultipartFile userPart = new MockMultipartFile("userCreateRequest", "","application/json", objectMapper.writeValueAsString(userCreateRequest).getBytes()); + + ResultActions resultUserActions = createUser(userPart, profilePart); + String firstUserJsonResponse = resultUserActions.andReturn().getResponse().getContentAsString(); + UserResponse userResponse = objectMapper.readValue(firstUserJsonResponse, UserResponse.class); + UUID authorId = userResponse.getId(); + + ChannelCreateRequest channelCreateRequest = new ChannelCreateRequest("공개 채널", "공개 채널"); + + ResultActions resultChannelActions = createPublicChannel(channelCreateRequest); + String jsonResponse = resultChannelActions.andReturn().getResponse().getContentAsString(); + ChannelResponse channelResponse = objectMapper.readValue(jsonResponse, ChannelResponse.class); + UUID channelId = channelResponse.getId(); + + MessageCreateRequest messageCreateRequest = new MessageCreateRequest("content",channelId, authorId); + MockMultipartFile messagePart = new MockMultipartFile("messageCreateRequest", "","application/json", objectMapper.writeValueAsString(messageCreateRequest).getBytes()); + MockMultipartFile messageImagePart = new MockMultipartFile("image", "test-image.jpg", "image/jpeg", "".getBytes()); + ResultActions resultActions = createMessage(messagePart, messageImagePart); + + String messageJsonResponse = resultActions.andReturn().getResponse().getContentAsString(); + MessageResponse messageResponse = objectMapper.readValue(messageJsonResponse, MessageResponse.class); + UUID messageId = messageResponse.getId(); + + //when & then + mockMvc.perform(delete("/api/messages/{messageId}",messageId)) + .andExpect(status().isNoContent()); + } + + + ResultActions createUser(MockMultipartFile userPart, MockMultipartFile profilePart) throws Exception { + return mockMvc.perform(multipart("/api/users") + .file(userPart) + .file(profilePart) + .contentType(MediaType.MULTIPART_FORM_DATA)); + } + + private ResultActions createPublicChannel(ChannelCreateRequest request) throws Exception { + return mockMvc.perform( + post("/api/channels/public") + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)); + } + + private ResultActions createMessage(MockMultipartFile messagePart, MockMultipartFile messageImagePart) throws Exception { + return mockMvc.perform(multipart("/api/messages") + .file(messagePart) + .file(messageImagePart) + .contentType(MediaType.MULTIPART_FORM_DATA)); + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/integration/UserIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/integration/UserIntegrationTest.java new file mode 100644 index 000000000..1a3e2013b --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/integration/UserIntegrationTest.java @@ -0,0 +1,120 @@ +package com.sprint.mission.discodeit.integration; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sprint.mission.discodeit.dto.user.UserResponse; +import com.sprint.mission.discodeit.dto.user.request.UserCreateRequest; +import com.sprint.mission.discodeit.dto.user.request.UserUpdateRequest; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +public class UserIntegrationTest extends IntegrationTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("사용자 생성 통합 테스트") + void createUser() throws Exception { + //given + UserCreateRequest userCreateRequest = new UserCreateRequest("test","test@test.com","test",null); + MockMultipartFile profilePart = new MockMultipartFile("profile", "test-profile.jpg", "image/jpeg", "".getBytes()); + MockMultipartFile userPart = new MockMultipartFile("userCreateRequest", "","application/json", objectMapper.writeValueAsString(userCreateRequest).getBytes()); + + //when + ResultActions resultActions = createUser(userPart, profilePart); + + //when & then + resultActions + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.username").value("test")) + .andExpect(jsonPath("$.email").value("test@test.com")) + .andExpect(jsonPath("$.profile.fileName").value("test-profile.jpg")) + .andExpect(jsonPath("$.profile.contentType").value("image/jpeg")) + .andExpect(jsonPath("$.profile.size").value(0)) + .andExpect(jsonPath("$.online").value(true)); + } + + @Test + @DisplayName("사용자 업데이트 통합 테스트") + void updateUser() throws Exception { + //given + UserCreateRequest userCreateRequest = new UserCreateRequest("test","test@test.com","test",null); + MockMultipartFile profilePart = new MockMultipartFile("profile", "test-profile.jpg", "image/jpeg", "".getBytes()); + MockMultipartFile userPart = new MockMultipartFile("userCreateRequest", "","application/json", objectMapper.writeValueAsString(userCreateRequest).getBytes()); + + ResultActions resultActions = createUser(userPart, profilePart); + + UserUpdateRequest userUpdateRequest = new UserUpdateRequest("update","update@update.com","update"); + MockMultipartFile userUpdatePart = new MockMultipartFile("userUpdateRequest", "","application/json", objectMapper.writeValueAsString(userUpdateRequest).getBytes()); + MockMultipartFile profileUpdatePart = new MockMultipartFile("profile", "update-profile.jpg", "image/jpeg", "".getBytes()); + + // 응답 본문을 변환 (예: UserResponse.class) + String json = resultActions.andReturn().getResponse().getContentAsString(); + System.out.println(json); + + UserResponse userResponse = objectMapper.readValue(json, UserResponse.class); + UUID userId = userResponse.getId(); + + //when & then + mockMvc.perform(multipart("/api/users/{userId}",userId) + .file(userUpdatePart) + .file(profileUpdatePart) + .contentType(MediaType.MULTIPART_FORM_DATA) + .with(request -> { + request.setMethod("PATCH"); + return request; + })) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.username").value("update")) + .andExpect(jsonPath("$.email").value("update@update.com")) + .andExpect(jsonPath("$.profile.fileName").value("update-profile.jpg")) + .andExpect(jsonPath("$.profile.contentType").value("image/jpeg")) + .andExpect(jsonPath("$.profile.size").value(0)) + .andExpect(jsonPath("$.online").value(true)); + } + + @Test + @DisplayName("사용자 삭제 통합테스트") + void deleteUser() throws Exception { + //given + UserCreateRequest userCreateRequest = new UserCreateRequest("test", "test@test.com", "test", null); + MockMultipartFile profilePart = new MockMultipartFile("profile", "test-profile.jpg", "image/jpeg", + "".getBytes()); + MockMultipartFile userPart = new MockMultipartFile("userCreateRequest", "", "application/json", + objectMapper.writeValueAsString(userCreateRequest).getBytes()); + + ResultActions resultActions = createUser(userPart, profilePart); + + UserResponse userResponse = objectMapper.readValue(resultActions.andReturn().getResponse().getContentAsString(), + UserResponse.class); + UUID userId = userResponse.getId(); + + //when & then + mockMvc.perform(delete("/api/users/{userId}", userId) + ) + .andExpect(status().isNoContent()); + } + + private ResultActions createUser(MockMultipartFile userPart, MockMultipartFile profilePart) throws Exception { + return mockMvc.perform(multipart("/api/users") + .file(userPart) + .file(profilePart) + .contentType(MediaType.MULTIPART_FORM_DATA)); + } + +} diff --git a/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java new file mode 100644 index 000000000..6fac80930 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.repository; + +import org.springframework.beans.factory.annotation.Autowired; + +public class ChannelRepositoryTest extends RepositoryTest { + @Autowired + private ChannelRepository channelRepository; + +} diff --git a/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java new file mode 100644 index 000000000..14fa9a0a3 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java @@ -0,0 +1,163 @@ +package com.sprint.mission.discodeit.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.User; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.test.util.ReflectionTestUtils; + +public class MessageRepositoryTest extends RepositoryTest { + + @Autowired + private MessageRepository messageRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ChannelRepository channelRepository; + + private User firstUser; + private User secondUser; + private Channel firstChannel; + private Channel secondChannel; + private Message firstMessage; + private Message secondMessage; + private Message thirdMessage; + + @BeforeEach + void setup() { + firstUser = User.builder() + .username("first") + .email("first@first.com") + .password("first") + .build(); + secondUser = User.builder() + .username("second") + .email("second@second.com") + .password("second") + .build(); + firstChannel = Channel.builder() + .type(ChannelType.PRIVATE) + .build(); + secondChannel = Channel.builder() + .type(ChannelType.PRIVATE) + .build(); + + } + + @Test + @DisplayName("채널에 속한 메시지를 성공적으로 조회한다") + void findAllByChannelIdSuccess() { + //given + firstMessage = createMessageWithChannel(firstChannel); + secondMessage = createMessageWithChannel(firstChannel); + channelRepository.save(firstChannel); + userRepository.save(firstUser); + UUID firstChannelId = firstChannel.getId(); + messageRepository.save(firstMessage); + messageRepository.save(secondMessage); + + //when + List messages = messageRepository.findAllByChannelId(firstChannelId); + + //then + assertThat(messages).hasSize(2); + + } + + @Test + @DisplayName("없는 채널의 메시지 조회 시도 시 빈 리스트를 반환한다") + void findAllByChannelIdWillReturnEmptyListWhenChannelNotExist() { + //given + UUID notExistChannelId = UUID.randomUUID(); + + //when + List message = messageRepository.findAllByChannelId(notExistChannelId); + + //then + Assertions.assertThat(message).isEmpty(); + + } + + + @Test + @DisplayName("채널에 속한 메시지를 성공적으로 삭제한다") + void deleteAllByChannelIdSuccess() { + //given + firstMessage = createMessageWithChannel(firstChannel); + secondMessage = createMessageWithChannel(firstChannel); + + channelRepository.save(firstChannel); + userRepository.save(firstUser); + messageRepository.save(firstMessage); + messageRepository.save(secondMessage); + UUID firstChannelId = firstChannel.getId(); + + //when + messageRepository.deleteAllByChannelId(firstChannelId); + + //then + assertThat(messageRepository.findAllByChannelId(firstChannelId)).isEmpty(); + } + + @Test + @DisplayName("채널 ID와 생성 시간으로 메시지를 페이징하여 조회한다") + void findPagedMessageByChannelIdAndCreatedAt() { + //given + Message firstMessage = createMessageWithChannel(firstChannel); + Message secondMessage = createMessageWithChannel(firstChannel); + Message thirdMessage = createMessageWithChannel(firstChannel); + + channelRepository.save(firstChannel); + userRepository.save(firstUser); + messageRepository.save(firstMessage); + messageRepository.save(secondMessage); + messageRepository.save(thirdMessage); + + Instant now = Instant.now(); + Instant beforeFiveMinutes = now.minus(5, ChronoUnit.MINUTES); + Instant afterFiveMinutes = now.plus(5, ChronoUnit.MINUTES); + + ReflectionTestUtils.setField(firstMessage, "createdAt", beforeFiveMinutes); + ReflectionTestUtils.setField(secondMessage, "createdAt", now); + ReflectionTestUtils.setField(thirdMessage, "createdAt", afterFiveMinutes); + + UUID firstChannelId = firstChannel.getId(); + + //when + Slice message = messageRepository.findChannelMessagesByCursor(firstChannelId, afterFiveMinutes, + PageRequest.of(0, 2)); + + //then + Assertions.assertThat(message.getContent()).hasSize(2); + + //when + message = messageRepository.findChannelMessagesByCursor(firstChannelId, afterFiveMinutes, PageRequest.of(1, 2)); + + //then + Assertions.assertThat(message.getContent()).hasSize(1); + + } + + private Message createMessageWithChannel(Channel channel) { + return Message.builder() + .channel(channel) + .author(firstUser) + .content("test") + .build(); + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/repository/RepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/RepositoryTest.java new file mode 100644 index 000000000..168c1fca2 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/RepositoryTest.java @@ -0,0 +1,9 @@ +package com.sprint.mission.discodeit.repository; + +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +public abstract class RepositoryTest { +} diff --git a/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java new file mode 100644 index 000000000..8008d6b81 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java @@ -0,0 +1,147 @@ +package com.sprint.mission.discodeit.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.User; +import java.util.Optional; +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.orm.jpa.TestEntityManager; +import org.springframework.dao.DataIntegrityViolationException; + +public class UserRepositoryTest extends RepositoryTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private TestEntityManager entityManager; + + private User user; + private BinaryContent profile; + + @Test + @DisplayName("중복된 이메일의 사용자는 저장할 수 없다") + void saveUserFailIfDuplicateEmail() { + //given + User user = User.builder() + .username("first") + .email("first@first.com") + .password("first") + .build(); + + User user2 = User.builder() + .username("second") + .email("first@first.com") + .password("second") + .build(); + + setRelationEntity(user); + setRelationEntity(user2); + + userRepository.saveAndFlush(user); + + //when & then + + assertThatThrownBy(() -> { + userRepository.saveAndFlush(user2); + }) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @DisplayName("중복된 이름의 사용자는 저장할 수 없다") + void saveUserFailIfDuplicateName() { + //given + final User user = User.builder() + .username("first") + .email("first@first.com") + .password("first") + .build(); + + final User user2 = User.builder() + .username("first") + .email("second@second.com") + .password("second") + .build(); + + setRelationEntity(user); + setRelationEntity(user2); + + userRepository.saveAndFlush(user); + + //when & then + assertThatThrownBy(() -> userRepository.saveAndFlush(user2)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @DisplayName("이메일로 이미 가입한 사용자를 조회할 수 있다") + void findUserByEmailIfUserExists() { + //given + User user = User.builder() + .username("first") + .email("first@first.com") + .password("first") + .build(); + + userRepository.save(user); + + //when + Optional findUser = userRepository.findByEmail(user.getEmail()); + + //then + assertThat(findUser).isPresent(); + assertThat(user).isEqualTo(findUser.get()); + } + + @Test + @DisplayName("사용자 이름으로 사용자를 조회할 수 있다") + void findUserByUsernameIfUserExists() { + //given + User user = User.builder() + .username("first") + .email("first@first.com") + .password("first") + .build(); + + userRepository.save(user); + + //when + Optional findUser = userRepository.findByUsername(user.getUsername()); + + //then + assertThat(findUser).isPresent(); + assertThat(user).isEqualTo(findUser.get()); + } + + @Test + @DisplayName("가입되지 않은 이름으로 사용자를 조회 시 빈 값을 반환한다") + void findUserByUsernameReturnOptionalEmptyIfUserNotExists() { + //when + Optional findUser = userRepository.findByUsername("notExist"); + + //then + assertThat(findUser).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 사용자는 이메일로 조회할 시 빈 값을 반환한다") + void findUserByEmailReturnOptionalEmptyIfUserNotExists() { + //when + Optional findUser = userRepository.findByEmail("notExist@email.com"); + + //then + assertThat(findUser).isEmpty(); + + } + + private void setRelationEntity(User user) { + profile = new BinaryContent("test", "test", 1024L, "test".getBytes()); + user.updateProfile(profile); + } + +} diff --git a/src/test/java/com/sprint/mission/discodeit/service/MockTest.java b/src/test/java/com/sprint/mission/discodeit/service/MockTest.java new file mode 100644 index 000000000..43a226eac --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/MockTest.java @@ -0,0 +1,10 @@ +package com.sprint.mission.discodeit.service; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.context.ActiveProfiles; + +@ExtendWith(MockitoExtension.class) +@ActiveProfiles("test") +public abstract class MockTest { +} diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java new file mode 100644 index 000000000..7493402d2 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java @@ -0,0 +1,219 @@ +package com.sprint.mission.discodeit.service.basic; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +import com.sprint.mission.discodeit.constant.ChannelErrorCode; +import com.sprint.mission.discodeit.dto.channel.ChannelResponse; +import com.sprint.mission.discodeit.dto.channel.request.ChannelCreateServiceRequest; +import com.sprint.mission.discodeit.dto.channel.request.ChannelUpdateServiceRequest; +import com.sprint.mission.discodeit.dto.channel.request.PrivateChannelCreateServiceRequest; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.ReadStatus; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.ChannelException; +import com.sprint.mission.discodeit.mapper.ChannelMapper; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.ReadStatusRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.MockTest; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; + + +public class BasicChannelServiceTest extends MockTest { + + @Mock + private ChannelRepository channelRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ReadStatusRepository readStatusRepository; + + @Mock + private MessageRepository messageRepository; + + @Mock + private ChannelMapper channelMapper; + + @InjectMocks + private BasicChannelService basicChannelService; + + private Channel publicChannel; + private Channel privateChannel; + private User user; + + private UUID publicChannelId; + private UUID privateChannelId; + private UUID userId; + + private ChannelResponse publicChannelResponse; + private ChannelResponse privateChannelResponse; + + private ReadStatus publicReadStatus; + private ReadStatus privateReadStatus; + + @BeforeEach + void setUp() { + publicChannelId = UUID.randomUUID(); + privateChannelId = UUID.randomUUID(); + userId = UUID.randomUUID(); + + publicChannel = Channel.builder() + .name("public") + .description("public") + .type(ChannelType.PUBLIC) + .build(); + + privateChannel = Channel.builder() + .type(ChannelType.PRIVATE) + .build(); + + user = new User("test", "test", "test", null); + + ReflectionTestUtils.setField(user, "id", userId); + ReflectionTestUtils.setField(publicChannel, "id", publicChannelId); + ReflectionTestUtils.setField(privateChannel, "id", privateChannelId); + + publicChannelResponse = new ChannelResponse(publicChannelId, ChannelType.PUBLIC, "public", "public", null, + null); + privateChannelResponse = new ChannelResponse(privateChannelId, ChannelType.PRIVATE, "private", "private", null, + null); + + publicReadStatus = ReadStatus.builder().user(user).channel(publicChannel).build(); + privateReadStatus = ReadStatus.builder().user(user).channel(privateChannel).build(); + } + + @Test + @DisplayName("Public Channel 생성 성공") + void createPublicChannelSuccess() { + //given + Channel channel = publicChannel; + ChannelResponse channelResponse = publicChannelResponse; + ChannelCreateServiceRequest channelCreateServiceRequest = ChannelCreateServiceRequest.builder().name("public") + .description("public").build(); + given(channelRepository.save(any(Channel.class))).willReturn(channel); + given(channelMapper.toEntity(any(ChannelCreateServiceRequest.class), any(ChannelType.class))).willReturn(channel); + given(channelMapper.toResponse(any(Channel.class))).willReturn(channelResponse); + + //when + ChannelResponse result = basicChannelService.createPublicChannel(channelCreateServiceRequest); + + //then + assertThat(result).isEqualTo(channelResponse); + then(channelRepository).should(times(1)).save(any(Channel.class)); + } + + @Test + @DisplayName("Private Channel 생성 성공") + void createPrivateChannelSuccess() { + //given + Channel channel = privateChannel; + ChannelResponse channelResponse = privateChannelResponse; + PrivateChannelCreateServiceRequest channelCreateServiceRequest = PrivateChannelCreateServiceRequest.builder().participantIds( + List.of(userId)).build(); + given(channelRepository.save(any(Channel.class))).willReturn(channel); + given(channelMapper.toEntity(any(PrivateChannelCreateServiceRequest.class), any(ChannelType.class))).willReturn(channel); + given(channelMapper.toResponse(any(Channel.class))).willReturn(channelResponse); + given(userRepository.findById(any(UUID.class))).willReturn(Optional.of(user)); + + //when + ChannelResponse result = basicChannelService.createPrivateChannel(channelCreateServiceRequest); + + //then + assertThat(result).isEqualTo(channelResponse); + then(channelRepository).should(times(1)).save(any(Channel.class)); + } + + @Test + @DisplayName("User Id로 채널들을 조회한다") + void findChannelsByUserIdSuccess() { + //given + List readStatuses = List.of(publicReadStatus, privateReadStatus); + List channels = List.of(publicChannel, privateChannel); + List channelResponses = List.of(publicChannelResponse, publicChannelResponse); + given(readStatusRepository.findAllByUserId(userId)).willReturn(readStatuses); + given(channelRepository.findAll()).willReturn(channels); + given(channelMapper.toResponse(any(Channel.class))).willReturn(publicChannelResponse); + + //when + List result = basicChannelService.findAllChannelsByUserId(userId); + + //then + assertThat(result).isEqualTo(channelResponses); + } + + @Test + @DisplayName("Public Channel 수정 성공") + void updatePublicChannelSuccess() { + //given + String updateName = "updateName"; + String updateDescription = "updateDescription"; + ChannelUpdateServiceRequest channelUpdateServiceRequest = ChannelUpdateServiceRequest.builder().channelId( + publicChannelId).name(updateName).description(updateDescription).build(); + given(channelRepository.findById(any(UUID.class))).willReturn(Optional.of(publicChannel)); + given(channelMapper.toResponse(any(Channel.class))).willReturn(publicChannelResponse); + + //when + ChannelResponse result = basicChannelService.updateChannel(channelUpdateServiceRequest); + + //then + assertThat(result).isEqualTo(publicChannelResponse); + } + + @Test + @DisplayName("존재하지 않는 채널 수정 시도 시 실패한다") + void updateChannelFailWhenChannelNotFound() { + //given + ChannelUpdateServiceRequest channelUpdateServiceRequest = ChannelUpdateServiceRequest.builder().channelId( + publicChannelId).name("updateName").description("updateDescription").build(); + given(channelRepository.findById(any(UUID.class))).willReturn(Optional.empty()); + + //when & then + assertThatThrownBy(() -> basicChannelService.updateChannel(channelUpdateServiceRequest)) + .isInstanceOf(ChannelException.class) + .hasMessage(ChannelErrorCode.CHANNEL_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("Private Channel 수정 시도 시 실패한다") + void updatePrivateChannelFaile() { + //given + Channel channel = privateChannel; + String updateName = "updateName"; + String updateDescription = "updateDescription"; + ChannelUpdateServiceRequest channelUpdateServiceRequest = ChannelUpdateServiceRequest.builder().channelId( + privateChannelId).name(updateName).description(updateDescription).build(); + given(channelRepository.findById(any(UUID.class))).willReturn(Optional.of(channel)); + + //when & then + assertThatThrownBy(() -> basicChannelService.updateChannel(channelUpdateServiceRequest)) + .isInstanceOf(ChannelException.class) + .hasMessage(ChannelErrorCode.PRIVATE_CHANNEL_NOT_EDITABLE.getMessage()); + } + + @Test + @DisplayName("채널 삭제 성공") + void deleteChannelSuccess() { + //when + basicChannelService.deleteChannel(publicChannelId); + + //then + then(channelRepository).should(times(1)).deleteById(any(UUID.class)); + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java new file mode 100644 index 000000000..7b09b84ca --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java @@ -0,0 +1,280 @@ +package com.sprint.mission.discodeit.service.basic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.times; + +import com.sprint.mission.discodeit.constant.MessageErrorCode; +import com.sprint.mission.discodeit.dto.message.MessageResponse; +import com.sprint.mission.discodeit.dto.message.request.MessageCreateServiceRequest; +import com.sprint.mission.discodeit.dto.message.request.MessageUpdateServiceRequest; +import com.sprint.mission.discodeit.entity.Channel; +import com.sprint.mission.discodeit.entity.ChannelType; +import com.sprint.mission.discodeit.entity.Message; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.ChannelException; +import com.sprint.mission.discodeit.exception.MessageException; +import com.sprint.mission.discodeit.exception.UserException; +import com.sprint.mission.discodeit.mapper.MessageMapper; +import com.sprint.mission.discodeit.mapper.PageResponseMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.ChannelRepository; +import com.sprint.mission.discodeit.repository.MessageAttachmentRepository; +import com.sprint.mission.discodeit.repository.MessageRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.MockTest; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.assertj.core.api.AssertionsForClassTypes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +public class BasicMessageServiceTest extends MockTest { + @Mock + private MessageRepository messageRepository; + + @Mock + private ChannelRepository channelRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private BinaryContentRepository binaryContentRepository; + + @Mock + private BinaryContentStorage binaryContentStorage; + + @Mock + private MessageAttachmentRepository messageAttachmentRepository; + + @Mock + private MessageMapper messageMapper; + + @Mock + private PageResponseMapper pageResponseMapper; + + @InjectMocks + private BasicMessageService messageService; + + private UUID userId; + private UUID channelId; + private UUID messageId; + + private User user; + private Channel channel; + private Message message; + private Message fileMessage; + private Message textAndFileMessage; + + private MessageCreateServiceRequest textMessageCreateServiceRequest; + private MessageCreateServiceRequest fileMessageCreateServiceRequest; + private MessageCreateServiceRequest textAndFileMessageCreateServiceRequest; + private MessageUpdateServiceRequest messageUpdateServiceRequest; + private MessageResponse messageResponse; + + @BeforeEach + void setUp() { + user = new User("testUser", "testUser", "testUser", null); + + channel = Channel.builder() + .name("testChannel") + .description("testChannel") + .type(ChannelType.PUBLIC) + .build(); + + message = Message.builder() + .content("testMessage") + .channel(channel) + .author(user) + .build(); + + messageId = UUID.randomUUID(); + ReflectionTestUtils.setField(message, "id", messageId); + + MockMultipartFile file = new MockMultipartFile( + "testFile", + "testFile", + "testFile", + new byte[0] + ); + + fileMessage = Message.builder() + .content("testFileMessage") + .channel(channel) + .author(user) + .build(); + +// fileMessage.addAttachment(file); + + textMessageCreateServiceRequest = MessageCreateServiceRequest.builder() + .channelId(channel.getId()) + .userId(user.getId()) + .build(); + + fileMessageCreateServiceRequest = MessageCreateServiceRequest.builder() + .channelId(channel.getId()) + .userId(user.getId()) + .build(); + + textAndFileMessageCreateServiceRequest = MessageCreateServiceRequest.builder() + .channelId(channel.getId()) + .userId(user.getId()) + .message("textAndFileMessage") + .build(); + + ReflectionTestUtils.setField(fileMessageCreateServiceRequest, "attachments", List.of(file)); + ReflectionTestUtils.setField(textAndFileMessageCreateServiceRequest, "attachments", List.of(file)); + + messageUpdateServiceRequest = MessageUpdateServiceRequest.builder() + .messageId(message.getId()) + .content("updateMessage") + .build(); + + messageResponse = new MessageResponse( + message.getId(), + message.getCreatedAt(), + message.getUpdatedAt(), + message.getContent(), + message.getChannel().getId(), null, null); + + } + + @Test + @DisplayName("텍스트만 포함한 메시지 생성 성공") + void createMessageOnlyContainTextSuccess() { + //given + given(channelRepository.findById(channelId)).willReturn(Optional.of(channel)); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(messageMapper.toEntity(any(MessageCreateServiceRequest.class), any(User.class), any(Channel.class))).willReturn(message); + given(messageRepository.save(any(Message.class))).willReturn(message); + given(messageMapper.toResponse(any(Message.class))).willReturn(messageResponse); + + //when + MessageResponse result = messageService.createMessage(textMessageCreateServiceRequest); + + //then + assertThat(result).isEqualTo(messageResponse); + then(messageRepository).should(times(1)).save(any(Message.class)); + } + + @Test + @DisplayName("파일만 포함하는 메시지 생성 성공") + void createMessageContainsOnlyFileSuccess() { + //given + given(channelRepository.findById(channelId)).willReturn(Optional.of(channel)); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(messageMapper.toEntity(any(MessageCreateServiceRequest.class), any(User.class), any(Channel.class))).willReturn(message); + given(messageRepository.save(any(Message.class))).willReturn(fileMessage); + given(messageMapper.toResponse(any(Message.class))).willReturn(messageResponse); + given(binaryContentRepository.saveAll(any(List.class))).willReturn(null); + + + //when + MessageResponse result = messageService.createMessage(fileMessageCreateServiceRequest); + + //then + assertThat(result).isEqualTo(messageResponse); + then(messageRepository).should(times(1)).save(any(Message.class)); + then(binaryContentRepository).should(times(1)).saveAll(any(List.class)); + + } + + @Test + @DisplayName("파일과 메시지를 포함하는 메시지 생성 성공") + void createMessageContainsTextAndMessage() { + //given + given(channelRepository.findById(channelId)).willReturn(Optional.of(channel)); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(messageMapper.toEntity(any(MessageCreateServiceRequest.class), any(User.class), any(Channel.class))).willReturn(message); + given(messageRepository.save(any(Message.class))).willReturn(textAndFileMessage); + given(messageMapper.toResponse(any(Message.class))).willReturn(messageResponse); + given(binaryContentRepository.saveAll(any(List.class))).willReturn(null); + + + //when + MessageResponse result = messageService.createMessage(fileMessageCreateServiceRequest); + + //then + assertThat(result).isEqualTo(messageResponse); + then(messageRepository).should(times(1)).save(any(Message.class)); + then(binaryContentRepository).should(times(1)).saveAll(any(List.class)); + + } + + @Test + @DisplayName("존재하지 않는 채널에 메시지 생성 시도 시 실패") + void createMessageFailWhenChannelDoesNotExist() { + //given + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(channelRepository.findById(channelId)).willReturn(Optional.empty()); + + //when & then + assertThatThrownBy(() -> messageService.createMessage(textMessageCreateServiceRequest)) + .isInstanceOf(ChannelException.class); + } + + @Test + @DisplayName("존재하지 않는 사용자가 메시지 생성 시도 시 실패") + void createMessageFailWhenUserDoesNotExist() { + //given + given(userRepository.findById(userId)).willReturn(Optional.empty()); + + //when & then + assertThatThrownBy(() -> messageService.createMessage(textMessageCreateServiceRequest)) + .isInstanceOf(UserException.class); + } + + @Test + @DisplayName("메세지 수정 성공") + void updateMessageSuccess() { + //given + given(messageRepository.findById(messageId)).willReturn(Optional.of(message)); + given(messageMapper.toResponse(any(Message.class))).willReturn(messageResponse); + + //when + MessageResponse result = messageService.updateContent(messageUpdateServiceRequest); + + //then + AssertionsForClassTypes.assertThat(result).isEqualTo(messageResponse); +// then(messageRepository).should(). + + } + + @Test + @DisplayName("없는 메시지 수정 시도 시 실패") + void updateMessageFailWhenMessageDoesNotExist() { + //given + willThrow(new MessageException(MessageErrorCode.MESSAGE_NOT_FOUND)).given(messageRepository).findById(any( + UUID.class)); + + //when & then + assertThatThrownBy(() -> messageService.updateContent(messageUpdateServiceRequest)) + .isInstanceOf(MessageException.class); + } + + @Test + @DisplayName("메시지 삭제 성공") + void deleteMessageSuccess() { + //given + given(messageRepository.findById(any(UUID.class))).willReturn(Optional.of(message)); + + //when + messageService.deleteMessage(messageId); + + //then + then(messageRepository).should().deleteById(messageId); + } + +} diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java new file mode 100644 index 000000000..3fef6caff --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java @@ -0,0 +1,232 @@ +package com.sprint.mission.discodeit.service.basic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +import com.sprint.mission.discodeit.constant.UserErrorCode; +import com.sprint.mission.discodeit.dto.user.UserResponse; +import com.sprint.mission.discodeit.dto.user.request.UserCreateServiceRequest; +import com.sprint.mission.discodeit.dto.user.request.UserUpdateServiceRequest; +import com.sprint.mission.discodeit.entity.BinaryContent; +import com.sprint.mission.discodeit.entity.User; +import com.sprint.mission.discodeit.exception.UserException; +import com.sprint.mission.discodeit.mapper.BinaryContentMapper; +import com.sprint.mission.discodeit.mapper.UserMapper; +import com.sprint.mission.discodeit.repository.BinaryContentRepository; +import com.sprint.mission.discodeit.repository.UserRepository; +import com.sprint.mission.discodeit.service.MockTest; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +class BasicUserServiceTest extends MockTest { + + @Mock + private UserRepository userRepository; + + @Mock + private UserMapper userMapper; + + @Mock + private BinaryContentMapper binaryContentMapper; + + @Mock + private BinaryContentStorage binaryContentStorage; + + @Mock + private BinaryContentRepository binaryContentRepository; + + @Spy + @InjectMocks + private BasicUserService basicUserService; + + private User userWithoutProfile; + private User userWithProfile; + private UUID defaultProfileUserId; + private UUID profileUserId; + private String username; + private String email; + private String password; + private UserCreateServiceRequest createRequestWithoutProfile; + private UserCreateServiceRequest createRequestWithProfile; + private UserResponse userResponse; + private UserResponse userResponseWithProfile; + private MockMultipartFile profile; + private UUID profileId; + + private BinaryContent binaryProfile; + + + @BeforeEach + void setUp() { + + // user without profile + username = "test"; + email = "test@test.com"; + password = "password"; + defaultProfileUserId = UUID.randomUUID(); + + userWithoutProfile = new User(username, email, password, null); + ReflectionTestUtils.setField(userWithoutProfile, "id", defaultProfileUserId); + + createRequestWithoutProfile = UserCreateServiceRequest.builder() + .username(username) + .email(email) + .password(password) + .build(); + + userResponse = new UserResponse(userWithoutProfile); + + // user with profile + + profileId = UUID.randomUUID(); + profile = new MockMultipartFile( + "file", // 파라미터 이름 + "test-image.jpg", // 원본 파일명 + "image/jpeg", // 컨텐츠 타입 + "content".getBytes() // 파일 내용 + ); + + binaryProfile = BinaryContent.builder() + .contentType("image/jpeg") + .bytes("content".getBytes()) + .build(); + ReflectionTestUtils.setField(binaryProfile, "id", profileId); + + userWithProfile = new User(username, email, password, null); + + createRequestWithProfile = UserCreateServiceRequest.builder() + .username(username) + .email(email) + .password(password) + .profile(profile) + .build(); + + userResponse = new UserResponse(userWithProfile); + + } + + @Test + @DisplayName("프로필 이미지 없는 User 생성 성공") + void createUserWithoutProfileSuccess() { + //given + given(userRepository.save(any(User.class))).willReturn(userWithoutProfile); + given(userMapper.toResponse(any(User.class))).willReturn(userResponse); + given(userMapper.toEntity(any(UserCreateServiceRequest.class))).willReturn(userWithoutProfile); + + //when + UserResponse result = basicUserService.createUser(createRequestWithoutProfile); + + //then + assertThat(result).isEqualTo(userResponse); + then(userRepository).should(times(1)).save(any(User.class)); //verify(userRepository, times(1)).save(any(User.class)); 와 동일 + } + + @Test + @DisplayName("프로필 이미지 있는 유저 생성 성공") + void createUserSuccessWithProfile() { + //given + given(userRepository.save(any(User.class))).willReturn(userWithProfile); + given(userMapper.toResponse(any(User.class))).willReturn(userResponse); + given(userMapper.toEntity(any(UserCreateServiceRequest.class))).willReturn(userWithoutProfile); + given(binaryContentRepository.save(any(BinaryContent.class))).willReturn(binaryProfile); + + //when + UserResponse result = basicUserService.createUser(createRequestWithProfile); + + //then + assertThat(result).isEqualTo(userResponse); + then(userRepository).should(times(1)).save(any(User.class)); //verify(userRepository, times(1)).save(any(User.class)); 와 동일 + then(binaryContentRepository).should(times(1)).save(any(BinaryContent.class)); +// then(binaryContentStorage).should(times(1)).put(any(UUID.class), any(byte[].class)); 테스트 불가능한 부분 + + } + + @Test + @DisplayName("중복 이메일 User 생성 시도 시 실패") + void createUserFailWhenEmailDuplicated() { + //given + given(userRepository.findByEmail(eq(email))).willThrow(UserException.class); + + //when & then + assertThatThrownBy(() -> basicUserService.createUser(createRequestWithoutProfile)) + .isInstanceOf(UserException.class); + + } + + @Test + @DisplayName("중복 이름 User 생성 시도 시 실패") + void createUserFailWhenUsernameDuplicated() { + //given + given(userRepository.findByUsername(eq(username))).willThrow(UserException.class); + + //when & then + assertThatThrownBy(() -> basicUserService.createUser(createRequestWithoutProfile)) + .isInstanceOf(UserException.class); + } + + + @Test + @DisplayName("User 업데이트 성공") + void updateUserSuccess() { + //given + String updateUsername = "update"; + String updateEmail = "update@update.com"; + String updatePassword = "updatePassword"; + + UserUpdateServiceRequest updateRequest = UserUpdateServiceRequest.builder() + .userId(defaultProfileUserId) + .newUsername(updateUsername) + .newEmail(updateEmail) + .newPassword(updatePassword) + .build(); + + given(userRepository.findById(any(UUID.class))).willReturn(Optional.of(userWithoutProfile)); + given(userMapper.toResponse(any(User.class))).willReturn(userResponse); + + //when + UserResponse result = basicUserService.updateUser(updateRequest); + + //then + assertThat(result).isEqualTo(userResponse); + + + } + + @Test + @DisplayName("존재하지 않는 User 업데이트 시도 시 실패") + void updateUserFailWhenUserNotExist() { + //given + UserUpdateServiceRequest updateRequest = UserUpdateServiceRequest.builder() + .userId(defaultProfileUserId) + .build(); + + //when & then + assertThatThrownBy(() -> basicUserService.updateUser(updateRequest)) + .isInstanceOf(UserException.class) + .hasMessage(UserErrorCode.USER_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("User 삭제 성공") + void deleteUserSuccess() { + //when + basicUserService.deleteUser(defaultProfileUserId); + + //then + then(userRepository).should(times(1)).deleteById(any(UUID.class)); + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java b/src/test/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java new file mode 100644 index 000000000..fb0e03d3d --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java @@ -0,0 +1,201 @@ +package com.sprint.mission.discodeit.storage.s3; + +import java.io.FileInputStream; +import java.io.IOException; +import java.time.Duration; +import java.util.Properties; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; + +@Slf4j +@Disabled +public class AWSS3Test { + + private static String accessKey; + private static String secretKey; + private static String region; + private static String bucketName; + + private S3Client s3Client; + private S3Presigner s3Presigner; + private String testKey; + + @BeforeAll + static void loadProperties() throws IOException { + Properties properties = new Properties(); + try (FileInputStream fis = new FileInputStream(".env")) { + properties.load(fis); + accessKey = properties.getProperty("AWS_S3_ACCESS_KEY"); + secretKey = properties.getProperty("AWS_S3_SECRET_KEY"); + region = properties.getProperty("AWS_S3_REGION"); + bucketName = properties.getProperty("AWS_S3_BUCKET"); + } catch (IOException e) { + e.printStackTrace(); + throw new IOException(e); + } + } + + @BeforeEach + void setUp() throws Exception { + testKey = UUID.randomUUID().toString(); + + s3Client = S3Client.builder() + .region(Region.of(region)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create( + accessKey, + secretKey + ) + ) + ) + .build(); + + s3Presigner = S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create( + accessKey, + secretKey + ) + ) + ) + .build(); + } + + @Test + @DisplayName("S3에 파일 업로드를 성공한다") + void uploadFileToS3_Success() throws Exception { + //given + MockMultipartFile file = new MockMultipartFile("file", "test.txt", "text/plain", "Hello, World!".getBytes()); + + + //when & then + try{ + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucketName) + .key(testKey) + .contentType(file.getContentType()) + .build(); + + s3Client.putObject(request, + RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + log.info("파일 업로드 성공"); + } catch(S3Exception e) { + log.error("파일 업로드 실패 : {}", e.getMessage()); + } + + } + + @Test + @DisplayName("S3 파일 다운로드 테스트 성공") + void downloadFileFromS3_Success() throws Exception { + //given + MockMultipartFile file = new MockMultipartFile("file", "test.txt", "text/plain", "Hello, World!".getBytes()); + + try{ + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucketName) + .key(testKey) + .contentType(file.getContentType()) + .build(); + + s3Client.putObject(request, + RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + log.info("파일 업로드 성공"); + } catch(S3Exception e) { + log.error("파일 업로드 실패 : {}", e.getMessage()); + } + + //when & then + try { + GetObjectRequest request = GetObjectRequest.builder() + .bucket(bucketName) + .key(testKey) + .build(); + + String result =s3Client.getObject(request).toString(); + log.info("파일 다운로드 성공 : {}", result); + } catch (S3Exception e) { + log.error("파일 다운로드 실패 : {}", e.getMessage()); + } + + } + + @Test + @DisplayName("PresignedUrl 생성 테스트") + void createPresignedUrl_Success() throws Exception { + //given + MockMultipartFile file = new MockMultipartFile("file", "test.txt", "text/plain", "Hello, World!".getBytes()); + + try{ + PutObjectRequest uploadRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(testKey) + .contentType(file.getContentType()) + .build(); + + s3Client.putObject(uploadRequest, + RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + log.info("파일 업로드 성공"); + } catch(S3Exception e) { + log.error("파일 업로드 실패 : {}", e.getMessage()); + } + + //when & then + try { + GetObjectRequest request = GetObjectRequest.builder() + .bucket(bucketName) + .key(testKey) + .build(); + + GetObjectPresignRequest presignedRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(15)) + .getObjectRequest(request) + .build(); + + PresignedGetObjectRequest presignedUrl = s3Presigner.presignGetObject(presignedRequest); + + log.info("생성된 presignedUrl : {}", presignedUrl.url()); + } catch (S3Exception e) { + log.error("presignedUrl 생성 실패 {}", e.getMessage()); + } + + } + + @AfterEach + void cleanup() { + try { + DeleteObjectRequest request = DeleteObjectRequest.builder() + .bucket(bucketName) + .key(testKey) + .build(); + s3Client.deleteObject(request); + log.info("파일 삭제 성공"); + } catch (S3Exception e) { + log.error("파일 삭제 실패 : {}", e.getMessage()); + } + } + + +} diff --git a/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java b/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java new file mode 100644 index 000000000..1ae09773b --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java @@ -0,0 +1,108 @@ +package com.sprint.mission.discodeit.storage.s3; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.InputStream; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +@Slf4j +@Disabled +@ActiveProfiles("test") +@SpringBootTest +@DisplayName("S3BinaryContentStorage 테스트") +class S3BinaryContentStorageTest { + + @Autowired + private S3BinaryContentStorage s3BinaryContentStorage; + + @Value("${discodeit.storage.s3.bucket}") + private String bucket; + + @Value("${discodeit.storage.s3.access-key}") + private String accessKey; + + @Value("${discodeit.storage.s3.secret-key}") + private String secretKey; + + @Value("${discodeit.storage.s3.region}") + private String region; + + private final UUID testId = UUID.randomUUID(); + private final byte[] testData = "테스트 데이터".getBytes(); + + + @Test + @DisplayName("S3에 파일 업로드 성공 테스트") + void put_success() { + // when + UUID resultId = s3BinaryContentStorage.put(testId, testData); + + // then + assertThat(resultId).isEqualTo(testId); + } + + @Test + @DisplayName("S3에서 파일 다운로드 성공 테스트") + void get_success() throws IOException { + //given + s3BinaryContentStorage.put(testId, testData); + + // when + InputStream result = s3BinaryContentStorage.get(testId); + + // then + assertThat(result).isNotNull(); + byte[] bytes = result.readAllBytes(); + assertThat(bytes).isEqualTo(testData); + } + + @Test + @DisplayName("Presigned URL 생성 성공 테스트") + void generatePresignedUrl_success() { + // when + String result = s3BinaryContentStorage.generatePresignedUrl(testId.toString(), "application/octet-stream"); + + // then + assertThat(result).isNotNull(); + } + + @AfterEach() + void cleanup() { + try{ + S3Client s3Client = S3Client.builder() + .region(Region.of(region)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + ) + ) + .build(); + + DeleteObjectRequest request = DeleteObjectRequest.builder() + .bucket(bucket) + .key(testId.toString()) + .build(); + + s3Client.deleteObject(request); + log.info("clean up 성공"); + } catch (S3Exception e) { + log.error("clean up 실패 : {}", e.getMessage()); + } + } +} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 000000000..7442bd736 --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1,44 @@ +spring: + datasource: + url: jdbc:h2:mem:discodeit;MODE=PostgreSQL + username: discodeit_user + password: discode1234 + driver-class-name: org.h2.Driver + multipart: + max-file-size: 50MB + max-request-size: 50MB + hikari: + maximum-pool-size: 10 + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + h2: + console: + enabled: true + logging: + com: + sprint: + mission: + discodeit: DEBUG + org: + hibernate: + SQL: DEBUG + orm: + jdbc: + bind: TRACE + +discodeit: + storage: + type: ${STORAGE_TYPE:local} # 기본 값 local + local: + root-path: ${STORAGE_LOCAL_ROOT_PATH:.discoedit/storage} + s3: + access-key: ${AWS_S3_ACCESS_KEY} + secret-key: ${AWS_S3_SECRET_KEY} + region: ${AWS_S3_REGION} + bucket: ${AWS_S3_BUCKET} + presigned-url-expiration: ${AWS_S3_PRESIGNED_URL_EXPIRATION:600} # 기본값 10분 + path: ./discodeit/local/storage